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;