diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 5d2d06f5c6..e11b1ab084 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -13045,5 +13045,45 @@ describe('GeminiChat', async () => { // The non-text part that split the XML must survive the rebuild. expect(parts.some((p) => p.inlineData)).toBe(true); }); + + it('recovers XML tool calls when the stream ends without a finish reason (#8003 shape)', async () => { + const xml = + 'a.ts'; + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + (async function* () { + yield xmlChunk(xml); // no finishReason — the #8003 shape + })(), + ); + + const stream = await chat.sendMessageStream( + 'gemini-pro', + { message: 'read the file' }, + 'prompt-xml-fallback-no-finish', + ); + + const chunks: GenerateContentResponse[] = []; + for await (const event of stream) { + if (event.type === StreamEventType.CHUNK) { + chunks.push(event.value); + } + } + + const syntheticChunk = chunks.find((c) => + c.candidates?.[0]?.content?.parts?.some((p) => p.functionCall), + ); + expect(syntheticChunk).toBeDefined(); + expect(syntheticChunk!.functionCalls).toHaveLength(1); + const fc = + syntheticChunk!.candidates![0]!.content!.parts![0]!.functionCall!; + expect(fc.name).toBe('read_file'); + expect(fc.args).toEqual({ file_path: 'a.ts' }); + + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + expect(lastEntry.parts?.some((p) => p.functionCall)).toBe(true); + expect( + lastEntry.parts?.some((p) => p.text && p.text.includes(' { '````'; expect(extractXmlToolCalls(text)).toEqual([]); }); + + it('treats a closing fence with an info string as content, not a close (CommonMark 4.5)', () => { + const text = + '````markdown\n' + + '```xml\n' + + invoke('run_shell_command', param('command', 'rm -rf /tmp/x')) + + '\n```xml\n' + + '````'; + expect(extractXmlToolCalls(text)).toEqual([]); + }); + + it('treats a closing fence with trailing text as content, not a close', () => { + const text = + '~~~markdown\n' + + invoke('run_shell_command', param('command', 'echo hi')) + + '\n~~~ end of examples\n' + + '~~~'; + expect(extractXmlToolCalls(text)).toEqual([]); + }); }); describe('tryRecoverXmlToolCalls', () => { @@ -373,4 +392,29 @@ describe('tryRecoverXmlToolCalls', () => { expect(result.functionCallParts).toEqual([]); expect(result.remainingText).toBe(text); }); + + it('does not recover an invoke when the closing fence carries an info string', () => { + const text = + '````markdown\n' + + '```xml\n' + + invoke('run_shell_command', param('command', 'rm -rf /tmp/x')) + + '\n```xml\n' + + '````'; + const result = tryRecoverXmlToolCalls(text); + expect(result.recovered).toBe(false); + expect(result.functionCallParts).toEqual([]); + expect(result.remainingText).toBe(text); + }); + + it('does not recover an invoke when the closing fence has trailing text', () => { + const text = + '~~~markdown\n' + + invoke('run_shell_command', param('command', 'echo hi')) + + '\n~~~ end of examples\n' + + '~~~'; + const result = tryRecoverXmlToolCalls(text); + expect(result.recovered).toBe(false); + expect(result.functionCallParts).toEqual([]); + expect(result.remainingText).toBe(text); + }); }); diff --git a/packages/core/src/core/xml-tool-call-fallback.ts b/packages/core/src/core/xml-tool-call-fallback.ts index 78e3446b5e..63940482a6 100644 --- a/packages/core/src/core/xml-tool-call-fallback.ts +++ b/packages/core/src/core/xml-tool-call-fallback.ts @@ -63,7 +63,8 @@ function stripDelimitingNewlines(value: string): string { * Tracks delimiter type and length so a fence is only closed by a run of * the same delimiter that is at least as long as the opener, consistent * with CommonMark §4.5 (a shorter same-delimiter run is content, not a - * close). + * close). A closing fence must also be whitespace-only after the delimiter + * run — CommonMark forbids an info string on a closing fence. */ function positionInsideFence(text: string, index: number): boolean { let openFence: { delim: string; len: number } | null = null; @@ -73,7 +74,11 @@ function positionInsideFence(text: string, index: number): boolean { const delim = m[2] ? '`' : '~'; const len = m[1].length; if (openFence === null) openFence = { delim, len }; - else if (openFence.delim === delim && len >= openFence.len) + else if ( + openFence.delim === delim && + len >= openFence.len && + line.slice(m[0].length).trim() === '' + ) openFence = null; } return openFence !== null;