textual-tool-calls.test.ts
1 import { describe, expect, it } from 'vitest' 2 3 import { 4 extractTextualToolCallsFromAssistantText, 5 sanitizeAssistantTextForDisplay, 6 } from '@/server/providers/openai-compatible' 7 8 describe('extractTextualToolCallsFromAssistantText', () => { 9 it('returns empty array if text is empty', () => { 10 expect(extractTextualToolCallsFromAssistantText('', ['web_fetch'])).toEqual([]) 11 expect(extractTextualToolCallsFromAssistantText(' ', ['web_fetch'])).toEqual([]) 12 }) 13 14 it('returns empty array if no tool names are enabled', () => { 15 expect( 16 extractTextualToolCallsFromAssistantText('<tool_call>web_fetch...</tool_call>', []) 17 ).toEqual([]) 18 }) 19 20 describe('XML-style <tool_call> parsing', () => { 21 it('extracts a single valid XML-style tool call', () => { 22 const text = `Here is the info: 23 <tool_call> 24 web_fetch 25 <arg_key>url</arg_key><arg_value>https://example.com</arg_value> 26 </tool_call>` 27 28 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 29 30 expect(calls).toHaveLength(1) 31 expect(calls[0].name).toBe('web_fetch') 32 expect(calls[0].callId).toMatch(/^textual-tool-call-web_fetch-\d+$/) 33 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ 34 url: 'https://example.com' 35 }) 36 }) 37 38 it('ignores disabled tools in XML format', () => { 39 const text = `<tool_call> 40 dangerous_bash 41 <arg_key>cmd</arg_key><arg_value>rm -rf /</arg_value> 42 </tool_call>` 43 44 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 45 expect(calls).toHaveLength(0) 46 }) 47 }) 48 49 describe('Python-style function call parsing', () => { 50 it('extracts a simple Python-style tool call', () => { 51 const text = `Let me fetch that for you. 52 web_fetch(url='https://news.ycombinator.com/')` 53 54 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch', 'web_search']) 55 56 expect(calls).toHaveLength(1) 57 expect(calls[0].name).toBe('web_fetch') 58 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ 59 url: 'https://news.ycombinator.com/' 60 }) 61 }) 62 63 it('handles double quotes in Python-style arguments', () => { 64 const text = `web_search(query="latest AI news 2026")` 65 const calls = extractTextualToolCallsFromAssistantText(text, ['web_search']) 66 67 expect(calls).toHaveLength(1) 68 expect(calls[0].name).toBe('web_search') 69 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ 70 query: 'latest AI news 2026' 71 }) 72 }) 73 74 it('handles multiple Python-style tool calls in same response', () => { 75 const text = ` 76 I will check these two sources. 77 web_fetch(url='https://site1.com') 78 web_fetch(url="https://site2.com") 79 ` 80 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 81 82 expect(calls).toHaveLength(2) 83 expect(calls[0].name).toBe('web_fetch') 84 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ url: 'https://site1.com' }) 85 expect(calls[1].name).toBe('web_fetch') 86 expect(JSON.parse(calls[1].argumentsJson)).toEqual({ url: 'https://site2.com' }) 87 expect(calls[0].callId).not.toBe(calls[1].callId) 88 }) 89 90 it('ignores disabled Python-style tools', () => { 91 const text = `system_exec(cmd='whoami')\nweb_fetch(url='https://example.com')` 92 // Only web_fetch is enabled 93 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 94 95 expect(calls).toHaveLength(1) 96 expect(calls[0].name).toBe('web_fetch') 97 }) 98 99 it('matches tool calls wrapped in markdown backticks', () => { 100 const text = "Let me do that. `web_fetch(url='https://news.ycombinator.com/')`" 101 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 102 103 expect(calls).toHaveLength(1) 104 expect(calls[0].name).toBe('web_fetch') 105 }) 106 107 it('matches tool calls inside markdown code blocks', () => { 108 const text = "Let me do that.\n```python\nweb_fetch(url='https://news.ycombinator.com/')\n```" 109 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 110 111 expect(calls).toHaveLength(1) 112 expect(calls[0].name).toBe('web_fetch') 113 }) 114 115 it('does not match words that just happen to include the tool name', () => { 116 const text = `my_web_fetch(url='abc') and web_fetch_wrapper(x='y')` 117 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 118 119 // Since regex uses (?:^|\s), these should not match 120 expect(calls).toHaveLength(0) 121 }) 122 }) 123 }) 124 125 describe('Minimax-style XML parsing', () => { 126 it('extracts a single Minimax-style tool call', () => { 127 const text = `I will fetch the information. 128 <minimax:tool_call> 129 <invoke name="web_fetch"> 130 <parameter name="url">https://news.ycombinator.com/</parameter> 131 </invoke> 132 </minimax:tool_call>` 133 134 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 135 136 expect(calls).toHaveLength(1) 137 expect(calls[0].name).toBe('web_fetch') 138 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ 139 url: 'https://news.ycombinator.com/' 140 }) 141 }) 142 143 it('ignores disabled tools in Minimax format', () => { 144 const text = ` 145 <minimax:tool_call> 146 <invoke name="system_exec"> 147 <parameter name="cmd">ls</parameter> 148 </invoke> 149 </minimax:tool_call>` 150 151 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 152 expect(calls).toHaveLength(0) 153 }) 154 155 it('extracts multiple Minimax-style tool calls', () => { 156 const text = ` 157 <minimax:tool_call> 158 <invoke name="web_fetch"> 159 <parameter name="url">https://site1.com</parameter> 160 </invoke> 161 <invoke name="web_fetch"> 162 <parameter name="url">https://site2.com</parameter> 163 </invoke> 164 </minimax:tool_call>` 165 166 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 167 168 expect(calls).toHaveLength(2) 169 expect(calls[0].name).toBe('web_fetch') 170 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ url: 'https://site1.com' }) 171 expect(calls[1].name).toBe('web_fetch') 172 expect(JSON.parse(calls[1].argumentsJson)).toEqual({ url: 'https://site2.com' }) 173 }) 174 175 it('supports minimax invoke/parameter attributes with single quotes', () => { 176 const text = ` 177 <minimax:tool_call> 178 <invoke name='web_fetch'> 179 <parameter name='url'>https://news.ycombinator.com/</parameter> 180 </invoke> 181 </minimax:tool_call>` 182 183 const calls = extractTextualToolCallsFromAssistantText(text, ['web_fetch']) 184 185 expect(calls).toHaveLength(1) 186 expect(calls[0].name).toBe('web_fetch') 187 expect(JSON.parse(calls[0].argumentsJson)).toEqual({ 188 url: 'https://news.ycombinator.com/' 189 }) 190 }) 191 }) 192 193 describe('sanitizeAssistantTextForDisplay', () => { 194 it('strips minimax XML tool-call wrappers from assistant display text', () => { 195 const text = `I will fetch that for you. 196 <minimax:tool_call> 197 <invoke name="web_fetch"> 198 <parameter name="url">https://news.ycombinator.com/</parameter> 199 </invoke> 200 </minimax:tool_call> 201 The fetch completed.` 202 203 const sanitized = sanitizeAssistantTextForDisplay(text) 204 expect(sanitized).toContain('I will fetch that for you.') 205 expect(sanitized).toContain('The fetch completed.') 206 expect(sanitized).not.toContain('<minimax:tool_call>') 207 expect(sanitized).not.toContain('<invoke name="web_fetch">') 208 }) 209 210 it('strips downgraded tool call/result markers from assistant display text', () => { 211 const text = `[Tool Call: web_fetch] 212 Arguments: {"url":"https://news.ycombinator.com/"} 213 [Tool Result for ID call_1] 214 {"ok":true} 215 [Historical context: local tool loop] 216 Final answer for the user.` 217 218 const sanitized = sanitizeAssistantTextForDisplay(text) 219 expect(sanitized).toContain('Final answer for the user.') 220 expect(sanitized).not.toContain('[Tool Call:') 221 expect(sanitized).not.toContain('[Tool Result for ID') 222 expect(sanitized).not.toContain('[Historical context:') 223 }) 224 225 it('strips thinking tags while preserving final assistant text', () => { 226 const text = `<thinking>internal scratchpad</thinking> 227 <reasoning>more hidden notes</reasoning> 228 Final answer.` 229 230 expect(sanitizeAssistantTextForDisplay(text)).toBe('Final answer.') 231 }) 232 })