/ tests / textual-tool-calls.test.ts
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  })