openai-compatible-textual-tool-fallback.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 2 import type { ProviderChatInput } from '@/server/providers/types' 3 import { openAiCompatibleProvider } from '@/server/providers/openai-compatible' 4 import { performWebFetch } from '@/server/tools/web-fetch' 5 import { performWebSearch } from '@/server/tools/web-search' 6 import { recordToolExecutionLog } from '@/server/storage/tool-execution-logs' 7 8 vi.mock('@/server/costs/ledger', () => ({ 9 recordApiCall: vi.fn(), 10 })) 11 12 vi.mock('@/server/tools/web-fetch', () => ({ 13 performWebFetch: vi.fn(), 14 })) 15 16 vi.mock('@/server/tools/web-search', () => ({ 17 performWebSearch: vi.fn(), 18 })) 19 20 vi.mock('@/server/storage/tool-execution-logs', () => ({ 21 recordToolExecutionLog: vi.fn(), 22 })) 23 24 vi.mock('@/server/storage/chat-store', () => ({ 25 getUploadById: vi.fn(() => null), 26 })) 27 28 vi.mock('@/server/uploads/files', () => ({ 29 readUploadFile: vi.fn(), 30 })) 31 32 const originalEnv = { ...process.env } 33 const originalFetch = global.fetch 34 35 function jsonResponse(body: unknown, status = 200): Response { 36 return new Response(JSON.stringify(body), { 37 status, 38 headers: { 39 'Content-Type': 'application/json', 40 }, 41 }) 42 } 43 44 function wait(ms: number): Promise<void> { 45 return new Promise((resolve) => { 46 setTimeout(resolve, ms) 47 }) 48 } 49 50 describe('openAiCompatibleProvider textual tool-call fallback (chat completions)', () => { 51 beforeEach(() => { 52 vi.clearAllMocks() 53 process.env = { 54 ...originalEnv, 55 LLM_BASE_URL: 'https://example.test/v1', 56 LLM_CHAT_MODEL: 'minimax-m2', 57 LLM_TIMEOUT_MS: '30000', 58 } 59 global.fetch = vi.fn() as typeof fetch 60 }) 61 62 afterEach(() => { 63 process.env = originalEnv 64 global.fetch = originalFetch 65 }) 66 67 it('executes minimax XML tool calls in chat-completions mode and strips tags from continuation text', async () => { 68 vi.mocked(performWebFetch).mockResolvedValue({ 69 result: { 70 url: 'https://news.ycombinator.com/', 71 title: 'Hacker News', 72 content: 'Top stories...', 73 contentType: 'markdown', 74 truncated: false, 75 bytesRead: 1234, 76 }, 77 cached: false, 78 fetchTimeMs: 12, 79 }) 80 81 const fetchMock = vi.mocked(global.fetch) 82 fetchMock 83 .mockResolvedValueOnce( 84 jsonResponse({ 85 model: 'minimax-m2', 86 choices: [ 87 { 88 message: { 89 content: `I will fetch it.\n<minimax:tool_call>\n<invoke name="web_fetch">\n<parameter name="url">https://news.ycombinator.com/</parameter>\n</invoke>\n</minimax:tool_call>`, 90 }, 91 finish_reason: 'stop', 92 }, 93 ], 94 }), 95 ) 96 .mockResolvedValueOnce( 97 jsonResponse({ 98 model: 'minimax-m2', 99 choices: [ 100 { 101 message: { 102 content: 'Fetched the page successfully.', 103 }, 104 finish_reason: 'stop', 105 }, 106 ], 107 }), 108 ) 109 110 const input: ProviderChatInput = { 111 sessionId: 'session-tool-log-xml', 112 systemPrompt: 'You are helpful.', 113 compactedSummary: '', 114 memories: [], 115 messages: [ 116 { 117 role: 'user', 118 text: 'can you try the fetch once more', 119 attachments: [], 120 }, 121 ], 122 providerOverride: { 123 baseUrl: 'https://example.test/v1', 124 apiKey: null, 125 chatEndpointMode: 'chat_completions', 126 }, 127 modelOverride: 'minimax-m2', 128 allowDangerousBashTool: false, 129 } 130 131 const result = await openAiCompatibleProvider.generateReply(input) 132 133 expect(result.provider).toBe('openai-compatible') 134 expect(result.mocked).toBe(false) 135 expect(result.text).toContain('Fetched the page successfully.') 136 expect(result.text).toContain('<tool_result>') 137 expect(result.text).toContain('Executed 1 local tool call:') 138 expect(result.text).toContain('web_fetch (textual fallback): ok') 139 140 expect(performWebFetch).toHaveBeenCalledTimes(1) 141 expect(performWebFetch).toHaveBeenCalledWith({ 142 url: 'https://news.ycombinator.com/', 143 maxBytes: 500000, 144 }) 145 146 expect(fetchMock).toHaveBeenCalledTimes(2) 147 expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/chat/completions') 148 expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/chat/completions') 149 150 const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit 151 const secondPayload = JSON.parse(String(secondInit.body)) as { 152 messages?: Array<{ role?: string; content?: string }> 153 } 154 const contents = (secondPayload.messages ?? []) 155 .map((message) => (typeof message.content === 'string' ? message.content : '')) 156 .join('\n\n') 157 158 expect(contents).toContain('I will fetch it.') 159 expect(contents).toContain('Tool results (textual tool-call compatibility mode):') 160 expect(contents).not.toContain('<minimax:tool_call>') 161 expect(contents).not.toContain('<invoke name="web_fetch">') 162 expect(recordToolExecutionLog).toHaveBeenCalledWith( 163 expect.objectContaining({ 164 sessionId: 'session-tool-log-xml', 165 toolName: 'web_fetch', 166 source: 'textual', 167 status: 'ok', 168 }), 169 ) 170 }) 171 172 it('falls back from failed responses tool-loop to chat-completions textual loop for non-openai providers', async () => { 173 vi.mocked(performWebFetch).mockResolvedValue({ 174 result: { 175 url: 'https://news.ycombinator.com/', 176 title: 'Hacker News', 177 content: 'Top stories...', 178 contentType: 'markdown', 179 truncated: false, 180 bytesRead: 1234, 181 }, 182 cached: false, 183 fetchTimeMs: 10, 184 }) 185 186 const fetchMock = vi.mocked(global.fetch) 187 fetchMock 188 .mockResolvedValueOnce( 189 jsonResponse( 190 { 191 error: { 192 message: 'tools are unsupported for this backend on /responses', 193 }, 194 }, 195 400, 196 ), 197 ) 198 .mockResolvedValueOnce( 199 jsonResponse({ 200 model: 'minimax-m2', 201 choices: [ 202 { 203 message: { 204 content: `<minimax:tool_call>\n<invoke name='web_fetch'>\n<parameter name='url'>https://news.ycombinator.com/</parameter>\n</invoke>\n</minimax:tool_call>`, 205 }, 206 finish_reason: 'stop', 207 }, 208 ], 209 }), 210 ) 211 .mockResolvedValueOnce( 212 jsonResponse({ 213 model: 'minimax-m2', 214 choices: [ 215 { 216 message: { 217 content: 'Done after fallback.', 218 }, 219 finish_reason: 'stop', 220 }, 221 ], 222 }), 223 ) 224 225 const input: ProviderChatInput = { 226 systemPrompt: 'You are helpful.', 227 compactedSummary: '', 228 memories: [], 229 messages: [ 230 { 231 role: 'user', 232 text: 'please fetch https://news.ycombinator.com/ again', 233 attachments: [], 234 }, 235 ], 236 providerOverride: { 237 baseUrl: 'https://router.example.com/v1', 238 apiKey: null, 239 chatEndpointMode: 'auto', 240 }, 241 modelOverride: 'minimax-m2', 242 allowDangerousBashTool: false, 243 } 244 245 const result = await openAiCompatibleProvider.generateReply(input) 246 expect(result.text).toContain('Done after fallback.') 247 expect(result.text).toContain('<tool_result>') 248 expect(performWebFetch).toHaveBeenCalledTimes(1) 249 250 expect(fetchMock).toHaveBeenCalledTimes(3) 251 expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/responses') 252 expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/chat/completions') 253 expect(String(fetchMock.mock.calls[2]?.[0])).toContain('/chat/completions') 254 }) 255 256 it('falls back to chat-completions when responses returns status=failed with no assistant text during local tool routing', async () => { 257 vi.mocked(performWebFetch).mockResolvedValue({ 258 result: { 259 url: 'https://news.ycombinator.com/', 260 title: 'Hacker News', 261 content: 'Top stories...', 262 contentType: 'markdown', 263 truncated: false, 264 bytesRead: 1234, 265 }, 266 cached: false, 267 fetchTimeMs: 9, 268 }) 269 270 const fetchMock = vi.mocked(global.fetch) 271 fetchMock 272 .mockResolvedValueOnce( 273 jsonResponse({ 274 id: 'resp_tmp_failed_round', 275 model: 'arcee-ai/trinity-mini-20251201:free', 276 status: 'failed', 277 output: [], 278 }), 279 ) 280 .mockResolvedValueOnce( 281 jsonResponse({ 282 model: 'arcee-ai/trinity-mini-20251201:free', 283 choices: [ 284 { 285 message: { 286 content: `<minimax:tool_call>\n<invoke name='web_fetch'>\n<parameter name='url'>https://news.ycombinator.com/</parameter>\n</invoke>\n</minimax:tool_call>`, 287 }, 288 finish_reason: 'stop', 289 }, 290 ], 291 }), 292 ) 293 .mockResolvedValueOnce( 294 jsonResponse({ 295 model: 'arcee-ai/trinity-mini-20251201:free', 296 choices: [ 297 { 298 message: { 299 content: 'Recovered after failed responses round.', 300 }, 301 finish_reason: 'stop', 302 }, 303 ], 304 }), 305 ) 306 307 const input: ProviderChatInput = { 308 sessionId: 'session-failed-round-fallback', 309 systemPrompt: 'You are helpful.', 310 compactedSummary: '', 311 memories: [], 312 messages: [ 313 { 314 role: 'user', 315 text: 'please fetch https://news.ycombinator.com/', 316 attachments: [], 317 }, 318 ], 319 providerOverride: { 320 baseUrl: 'https://openrouter.example.com/v1', 321 apiKey: null, 322 chatEndpointMode: 'auto', 323 }, 324 modelOverride: 'arcee-ai/trinity-mini:free', 325 allowDangerousBashTool: false, 326 } 327 328 const result = await openAiCompatibleProvider.generateReply(input) 329 expect(result.text).toContain('Recovered after failed responses round.') 330 expect(result.text).toContain('web_fetch (textual fallback): ok') 331 332 expect(fetchMock).toHaveBeenCalledTimes(3) 333 expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/responses') 334 expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/chat/completions') 335 expect(String(fetchMock.mock.calls[2]?.[0])).toContain('/chat/completions') 336 }) 337 338 it('falls back to chat-completions for non-tool prompts when responses returns status=failed with empty output', async () => { 339 const fetchMock = vi.mocked(global.fetch) 340 fetchMock 341 .mockResolvedValueOnce( 342 jsonResponse({ 343 id: 'resp_tmp_failed_non_tool', 344 model: 'arcee-ai/trinity-mini-20251201:free', 345 status: 'failed', 346 output: [], 347 }), 348 ) 349 .mockResolvedValueOnce( 350 jsonResponse({ 351 model: 'arcee-ai/trinity-mini-20251201:free', 352 choices: [ 353 { 354 message: { 355 content: 'Recovered from failed responses non-tool round.', 356 }, 357 finish_reason: 'stop', 358 }, 359 ], 360 }), 361 ) 362 363 const input: ProviderChatInput = { 364 sessionId: 'session-failed-non-tool-fallback', 365 systemPrompt: 'You are helpful.', 366 compactedSummary: '', 367 memories: [], 368 messages: [ 369 { 370 role: 'user', 371 text: 'hello there', 372 attachments: [], 373 }, 374 ], 375 providerOverride: { 376 baseUrl: 'https://openrouter.example.com/v1', 377 apiKey: null, 378 chatEndpointMode: 'responses', 379 }, 380 modelOverride: 'arcee-ai/trinity-mini:free', 381 allowDangerousBashTool: false, 382 } 383 384 const result = await openAiCompatibleProvider.generateReply(input) 385 expect(result.text).toContain('Recovered from failed responses non-tool round.') 386 387 expect(fetchMock).toHaveBeenCalledTimes(2) 388 expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/responses') 389 expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/chat/completions') 390 }) 391 392 it('executes chat-completions native function tool calls when returned by provider', async () => { 393 vi.mocked(performWebFetch).mockResolvedValue({ 394 result: { 395 url: 'https://news.ycombinator.com/', 396 title: 'Hacker News', 397 content: 'Top stories...', 398 contentType: 'markdown', 399 truncated: false, 400 bytesRead: 1234, 401 }, 402 cached: false, 403 fetchTimeMs: 8, 404 }) 405 406 const fetchMock = vi.mocked(global.fetch) 407 fetchMock 408 .mockResolvedValueOnce( 409 jsonResponse({ 410 model: 'moonshotai/Kimi-K2.5', 411 choices: [ 412 { 413 message: { 414 role: 'assistant', 415 content: '', 416 tool_calls: [ 417 { 418 id: 'call_1', 419 type: 'function', 420 function: { 421 name: 'web_fetch', 422 arguments: 423 '{"url":"https://news.ycombinator.com/"}', 424 }, 425 }, 426 ], 427 }, 428 finish_reason: 'tool_calls', 429 }, 430 ], 431 }), 432 ) 433 .mockResolvedValueOnce( 434 jsonResponse({ 435 model: 'moonshotai/Kimi-K2.5', 436 choices: [ 437 { 438 message: { 439 content: 'Fetched via native function call.', 440 }, 441 finish_reason: 'stop', 442 }, 443 ], 444 }), 445 ) 446 447 const input: ProviderChatInput = { 448 systemPrompt: 'You are helpful.', 449 compactedSummary: '', 450 memories: [], 451 messages: [ 452 { 453 role: 'user', 454 text: 'please fetch https://news.ycombinator.com/', 455 attachments: [], 456 }, 457 ], 458 providerOverride: { 459 baseUrl: 'https://router.example.com/v1', 460 apiKey: null, 461 chatEndpointMode: 'chat_completions', 462 }, 463 modelOverride: 'moonshotai/Kimi-K2.5', 464 allowDangerousBashTool: false, 465 } 466 467 const result = await openAiCompatibleProvider.generateReply(input) 468 expect(result.text).toContain('Fetched via native function call.') 469 expect(result.text).toContain('web_fetch (function): ok') 470 expect(performWebFetch).toHaveBeenCalledTimes(1) 471 expect(fetchMock).toHaveBeenCalledTimes(2) 472 473 const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit 474 const secondPayload = JSON.parse(String(secondInit.body)) as { 475 messages?: Array<{ role?: string }> 476 } 477 const messageRoles = (secondPayload.messages ?? []) 478 .map((message) => message.role) 479 .filter(Boolean) 480 481 expect(messageRoles).toContain('tool') 482 }) 483 484 it('keeps responses tool loops alive across multiple rounds when each round has activity', async () => { 485 process.env.LLM_TIMEOUT_MS = '1000' 486 487 const fetchMock = vi.mocked(global.fetch) 488 fetchMock 489 .mockImplementationOnce(async () => { 490 await wait(700) 491 return jsonResponse({ 492 model: 'minimax-m2', 493 output_text: 'Let me check. get_current_time()', 494 }) 495 }) 496 .mockImplementationOnce(async () => { 497 await wait(700) 498 return jsonResponse({ 499 model: 'minimax-m2', 500 output_text: 'Done after the second round.', 501 }) 502 }) 503 504 const input: ProviderChatInput = { 505 sessionId: 'session-timeout-reset', 506 systemPrompt: 'You are helpful.', 507 compactedSummary: '', 508 memories: [], 509 messages: [ 510 { 511 role: 'user', 512 text: 'what time is it?', 513 attachments: [], 514 }, 515 ], 516 providerOverride: { 517 baseUrl: 'https://router.example.com/v1', 518 apiKey: null, 519 chatEndpointMode: 'responses', 520 }, 521 modelOverride: 'minimax-m2', 522 allowDangerousBashTool: false, 523 } 524 525 const result = await openAiCompatibleProvider.generateReply(input) 526 expect(result.text).toContain('Done after the second round.') 527 expect(result.text).toContain('get_current_time (textual fallback): ok') 528 expect(fetchMock).toHaveBeenCalledTimes(2) 529 expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/responses') 530 expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/responses') 531 }) 532 533 it('allows responses local tool loops to run beyond four rounds without forced fallback', async () => { 534 vi.mocked(performWebSearch).mockResolvedValue({ 535 results: [ 536 { 537 title: 'Result', 538 url: 'https://example.com/result', 539 snippet: 'Snippet', 540 }, 541 ], 542 provider: 'duckduckgo', 543 cached: false, 544 searchTimeMs: 5, 545 }) 546 vi.mocked(performWebFetch).mockResolvedValue({ 547 result: { 548 url: 'https://example.com/page', 549 title: 'Example page', 550 content: 'Example content', 551 contentType: 'markdown', 552 truncated: false, 553 bytesRead: 512, 554 }, 555 cached: false, 556 fetchTimeMs: 8, 557 }) 558 559 const fetchMock = vi.mocked(global.fetch) 560 fetchMock 561 .mockResolvedValueOnce( 562 jsonResponse({ 563 model: 'glm-4.7', 564 output_text: 565 "web_search(query='Iran attacks February 28 2026', maxResults=5)", 566 }), 567 ) 568 .mockResolvedValueOnce( 569 jsonResponse({ 570 model: 'glm-4.7', 571 output_text: 572 "web_fetch(url='https://apnews.com/live/live-updates-israel-iran-february-28-2026')", 573 }), 574 ) 575 .mockResolvedValueOnce( 576 jsonResponse({ 577 model: 'glm-4.7', 578 output_text: 579 "web_fetch(url='https://www.cnn.com/world/live-news/israel-iran-attack-02-28-26-hnk-intl')", 580 }), 581 ) 582 .mockResolvedValueOnce( 583 jsonResponse({ 584 model: 'glm-4.7', 585 output_text: "web_fetch(url='https://example.com/another-source')", 586 }), 587 ) 588 .mockResolvedValueOnce( 589 jsonResponse({ 590 model: 'glm-4.7', 591 output_text: 'Done after five rounds.', 592 }), 593 ) 594 595 const input: ProviderChatInput = { 596 sessionId: 'session-multi-round-tools', 597 systemPrompt: 'You are helpful.', 598 compactedSummary: '', 599 memories: [], 600 messages: [ 601 { 602 role: 'user', 603 text: 'search current Iran attack updates and summarize from multiple sources', 604 attachments: [], 605 }, 606 ], 607 providerOverride: { 608 baseUrl: 'https://router.example.com/v1', 609 apiKey: null, 610 chatEndpointMode: 'auto', 611 }, 612 modelOverride: 'glm-4.7', 613 allowDangerousBashTool: false, 614 } 615 616 const result = await openAiCompatibleProvider.generateReply(input) 617 expect(result.text).toContain('Done after five rounds.') 618 expect(result.text).toContain('Executed 4 local tool calls:') 619 620 expect(vi.mocked(performWebSearch)).toHaveBeenCalledTimes(1) 621 expect(performWebFetch).toHaveBeenCalledTimes(3) 622 623 expect(fetchMock).toHaveBeenCalledTimes(5) 624 for (const call of fetchMock.mock.calls) { 625 expect(String(call[0])).toContain('/responses') 626 expect(String(call[0])).not.toContain('/chat/completions') 627 } 628 }) 629 630 it('streams assistant preview text before local tool rounds fully complete', async () => { 631 vi.mocked(performWebSearch).mockResolvedValue({ 632 results: [ 633 { 634 title: 'AP', 635 url: 'https://apnews.com/example', 636 snippet: 'update', 637 }, 638 ], 639 provider: 'duckduckgo', 640 cached: false, 641 searchTimeMs: 7, 642 }) 643 644 const fetchMock = vi.mocked(global.fetch) 645 fetchMock 646 .mockResolvedValueOnce( 647 jsonResponse({ 648 model: 'glm-4.7', 649 output_text: 650 "I'll look that up now. web_search(query='Iran attacks February 28 2026', maxResults=5)", 651 }), 652 ) 653 .mockResolvedValueOnce( 654 jsonResponse({ 655 model: 'glm-4.7', 656 output_text: 'Here are the key updates.', 657 }), 658 ) 659 660 const input: ProviderChatInput = { 661 sessionId: 'session-stream-preview', 662 systemPrompt: 'You are helpful.', 663 compactedSummary: '', 664 memories: [], 665 messages: [ 666 { 667 role: 'user', 668 text: 'search the latest Iran attack updates', 669 attachments: [], 670 }, 671 ], 672 providerOverride: { 673 baseUrl: 'https://router.example.com/v1', 674 apiKey: null, 675 chatEndpointMode: 'auto', 676 }, 677 modelOverride: 'glm-4.7', 678 allowDangerousBashTool: false, 679 } 680 681 const streamReply = await openAiCompatibleProvider.streamReply(input) 682 const chunks: Array<{ type: string; text?: string }> = [] 683 for await (const chunk of streamReply.stream) { 684 if (chunk.type === 'delta') { 685 chunks.push({ type: 'delta', text: chunk.delta }) 686 continue 687 } 688 if (chunk.type === 'tool_complete') { 689 chunks.push({ type: 'tool_complete' }) 690 } 691 } 692 693 const firstPreviewDeltaIndex = chunks.findIndex( 694 (chunk) => 695 chunk.type === 'delta' && 696 (chunk.text ?? '').includes("I'll look that up now."), 697 ) 698 const firstToolCompleteIndex = chunks.findIndex( 699 (chunk) => chunk.type === 'tool_complete', 700 ) 701 702 expect(firstPreviewDeltaIndex).toBeGreaterThanOrEqual(0) 703 expect(firstToolCompleteIndex).toBeGreaterThan(firstPreviewDeltaIndex) 704 expect(chunks.some((chunk) => chunk.text?.includes('Here are the key updates.'))).toBe( 705 true, 706 ) 707 expect(fetchMock).toHaveBeenCalledTimes(2) 708 }) 709 710 it('extracts tagged thinking content from non-stream local rounds and emits it separately', async () => { 711 const fetchMock = vi.mocked(global.fetch) 712 fetchMock.mockResolvedValueOnce( 713 jsonResponse({ 714 model: 'glm-4.7', 715 output_text: 716 '<think>Checking sources and validating timeline.</think>No notable updates yet.', 717 }), 718 ) 719 720 const input: ProviderChatInput = { 721 sessionId: 'session-tagged-thinking', 722 systemPrompt: 'You are helpful.', 723 compactedSummary: '', 724 memories: [], 725 messages: [ 726 { 727 role: 'user', 728 text: 'search for updates', 729 attachments: [], 730 }, 731 ], 732 providerOverride: { 733 baseUrl: 'https://router.example.com/v1', 734 apiKey: null, 735 chatEndpointMode: 'auto', 736 }, 737 modelOverride: 'glm-4.7', 738 allowDangerousBashTool: false, 739 } 740 741 const streamReply = await openAiCompatibleProvider.streamReply(input) 742 let streamedText = '' 743 let streamedThinking = '' 744 for await (const chunk of streamReply.stream) { 745 if (chunk.type === 'delta') { 746 streamedText += chunk.delta 747 continue 748 } 749 if (chunk.type === 'thinking') { 750 streamedThinking += chunk.thinking 751 } 752 } 753 754 expect(streamedThinking).toContain('Checking sources and validating timeline.') 755 expect(streamedText).toContain('No notable updates yet.') 756 expect(streamedText).not.toContain('<think>') 757 expect(fetchMock).toHaveBeenCalledTimes(1) 758 }) 759 760 it('routes intermediate round narrative to thinking only when a tool continuation is still pending', async () => { 761 vi.mocked(performWebSearch).mockResolvedValue({ 762 results: [ 763 { 764 title: 'AP', 765 url: 'https://apnews.com/example', 766 snippet: 'update', 767 }, 768 ], 769 provider: 'duckduckgo', 770 cached: false, 771 searchTimeMs: 6, 772 }) 773 774 const fetchMock = vi.mocked(global.fetch) 775 fetchMock 776 .mockResolvedValueOnce( 777 jsonResponse({ 778 model: 'glm-4.7', 779 output_text: 780 "The user is asking about current attacks. web_search(query='Iran attacks February 28 2026', maxResults=3)", 781 reasoning: 'The user is asking about current attacks.', 782 }), 783 ) 784 .mockResolvedValueOnce( 785 jsonResponse({ 786 model: 'glm-4.7', 787 output_text: 'Based on current reports, here is the summary.', 788 }), 789 ) 790 791 const input: ProviderChatInput = { 792 sessionId: 'session-thinking-only-intermediate-round', 793 systemPrompt: 'You are helpful.', 794 compactedSummary: '', 795 memories: [], 796 messages: [ 797 { 798 role: 'user', 799 text: 'search for latest attack updates and summarize', 800 attachments: [], 801 }, 802 ], 803 providerOverride: { 804 baseUrl: 'https://router.example.com/v1', 805 apiKey: null, 806 chatEndpointMode: 'auto', 807 }, 808 modelOverride: 'glm-4.7', 809 allowDangerousBashTool: false, 810 } 811 812 const streamReply = await openAiCompatibleProvider.streamReply(input) 813 let streamedText = '' 814 let streamedThinking = '' 815 for await (const chunk of streamReply.stream) { 816 if (chunk.type === 'delta') { 817 streamedText += chunk.delta 818 continue 819 } 820 if (chunk.type === 'thinking') { 821 streamedThinking += chunk.thinking 822 } 823 } 824 825 expect(streamedThinking).toContain('The user is asking about current attacks.') 826 expect(streamedText).not.toContain('The user is asking about current attacks.') 827 expect(streamedText).toContain('Based on current reports, here is the summary.') 828 expect(fetchMock).toHaveBeenCalledTimes(2) 829 }) 830 831 it('keeps a slow in-flight responses tool-loop round alive via activity heartbeat', async () => { 832 process.env.LLM_TIMEOUT_MS = '1000' 833 834 const fetchMock = vi.mocked(global.fetch) 835 fetchMock.mockImplementationOnce(async () => { 836 await wait(2200) 837 return jsonResponse({ 838 model: 'minimax-m2', 839 output_text: 'Finished after a long round.', 840 }) 841 }) 842 843 const input: ProviderChatInput = { 844 sessionId: 'session-timeout-heartbeat', 845 systemPrompt: 'You are helpful.', 846 compactedSummary: '', 847 memories: [], 848 messages: [ 849 { 850 role: 'user', 851 text: 'say hello', 852 attachments: [], 853 }, 854 ], 855 providerOverride: { 856 baseUrl: 'https://router.example.com/v1', 857 apiKey: null, 858 chatEndpointMode: 'responses', 859 }, 860 modelOverride: 'minimax-m2', 861 allowDangerousBashTool: true, 862 } 863 864 const result = await openAiCompatibleProvider.generateReply(input) 865 expect(result.text).toContain('Finished after a long round.') 866 expect(fetchMock).toHaveBeenCalledTimes(1) 867 expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/responses') 868 }) 869 870 it('adds a no-tool-executed summary when bash is enabled but model does not call a tool', async () => { 871 const fetchMock = vi.mocked(global.fetch) 872 fetchMock.mockResolvedValueOnce( 873 jsonResponse({ 874 model: 'moonshotai/Kimi-K2.5', 875 choices: [ 876 { 877 message: { 878 content: 'head -50 packages/web-core/src/external-content.ts', 879 }, 880 finish_reason: 'stop', 881 }, 882 ], 883 }), 884 ) 885 886 const input: ProviderChatInput = { 887 systemPrompt: 'You are helpful.', 888 compactedSummary: '', 889 memories: [], 890 messages: [ 891 { 892 role: 'user', 893 text: 'can you run the bash tool again?', 894 attachments: [], 895 }, 896 ], 897 providerOverride: { 898 baseUrl: 'https://router.example.com/v1', 899 apiKey: null, 900 chatEndpointMode: 'chat_completions', 901 }, 902 modelOverride: 'moonshotai/Kimi-K2.5', 903 allowDangerousBashTool: true, 904 } 905 906 const result = await openAiCompatibleProvider.generateReply(input) 907 expect(result.text).toContain('head -50 packages/web-core/src/external-content.ts') 908 expect(result.text).toContain('<tool_result>') 909 expect(result.text).toContain('No local tools were executed in this turn.') 910 }) 911 })