fix(core): require whitespace-only closing fence and test no-finishReason path (#8037)

This commit is contained in:
Qwen Code Bot 2026-07-30 11:03:29 +00:00
parent 62c27c22d7
commit 00a325e894
3 changed files with 91 additions and 2 deletions

View file

@ -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 =
'<invoke name="read_file"><parameter name="file_path">a.ts</parameter></invoke>';
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('<invoke')),
).toBe(false);
});
});
});

View file

@ -209,6 +209,25 @@ describe('extractXmlToolCalls', () => {
'````';
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);
});
});

View file

@ -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;