mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-06 23:35:34 +00:00
fix(core): require whitespace-only closing fence and test no-finishReason path (#8037)
This commit is contained in:
parent
62c27c22d7
commit
00a325e894
3 changed files with 91 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue