mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 06:35:07 +00:00
fix(core): process the initial content delta in the inline thinking parser
A structured-reasoning stream whose first content chunk already carries a complete balanced <thinking> block (or just a complete opening tag) has no pending tag candidate yet, so the inline path added for issue #9348 never ran and the leading tag fell through to the leaked-tag check — a hard PROTOCOL_TAG_LEAK failure that depends only on stream chunking. Drop the pending-candidate requirement from the inline gate so the initial delta is held/demoted like later chunks, and add regression tests for the single-chunk balanced block and the single-chunk unclosed opener. Review feedback from @qqqys on the PR.
This commit is contained in:
parent
a3f1b2498a
commit
3cf2952eff
2 changed files with 60 additions and 1 deletions
|
|
@ -641,6 +641,61 @@ describe('OpenAIContentConverter', () => {
|
|||
expect(stream.pendingThinkingTagCandidate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('demotes a balanced inline thinking block that arrives in a single content chunk (issue #9348)', () => {
|
||||
// Chunking-dependent shape: the first content delta already carries the
|
||||
// complete balanced block, so no pending tag candidate exists yet when
|
||||
// the inline parser first sees the opening tag.
|
||||
const stream = withStreamParser();
|
||||
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };
|
||||
|
||||
converter.convertOpenAIChunkToGemini(
|
||||
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
|
||||
stream,
|
||||
);
|
||||
const block = converter.convertOpenAIChunkToGemini(
|
||||
streamChunk('block', {
|
||||
content: '<thinking>user asked X</thinking>',
|
||||
}),
|
||||
stream,
|
||||
);
|
||||
const answer = converter.convertOpenAIChunkToGemini(
|
||||
streamChunk('answer', { content: 'Answer here.' }, 'stop'),
|
||||
stream,
|
||||
);
|
||||
|
||||
expect(block.candidates?.[0]?.content?.parts).toEqual([
|
||||
{ thought: true, text: 'user asked X' },
|
||||
]);
|
||||
expect(answer.candidates?.[0]?.content?.parts).toEqual([
|
||||
{ text: 'Answer here.' },
|
||||
]);
|
||||
expect(stream.pendingThinkingTagCandidate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('holds an unclosed inline thinking block that starts in the first content chunk (issue #9348)', () => {
|
||||
// Same initial-delta path, but the block does not balance mid-stream:
|
||||
// it must be held for a closing tag, then rejected at stream end.
|
||||
const stream = withStreamParser();
|
||||
stream.responseParsingOptions = { contentOnlyThinkingTagLeaks: true };
|
||||
|
||||
converter.convertOpenAIChunkToGemini(
|
||||
streamChunk('reasoning', { reasoning_content: 'Let me think.' }),
|
||||
stream,
|
||||
);
|
||||
const opening = converter.convertOpenAIChunkToGemini(
|
||||
streamChunk('opening', { content: '<thinking>user asked X' }),
|
||||
stream,
|
||||
);
|
||||
|
||||
expect(opening.candidates?.[0]?.content?.parts).toEqual([]);
|
||||
expect(stream.pendingThinkingTagCandidate).toEqual({
|
||||
text: '<thinking>user asked X',
|
||||
});
|
||||
expect(() => finishStream(stream, 'stop')).toThrowError(
|
||||
expect.objectContaining({ type: 'PROTOCOL_TAG_LEAK' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still rejects an unclosed inline thinking block after reasoning (issue #9348 regression guard)', () => {
|
||||
// The fix must not weaken real leak detection: an opening tag that is
|
||||
// never balanced by a closing tag remains a PROTOCOL_TAG_LEAK.
|
||||
|
|
|
|||
|
|
@ -1667,9 +1667,13 @@ export function convertOpenAIChunkToGemini(
|
|||
// "[API Error: Model response leaked thinking tags.]" mid-session.
|
||||
// Hold the block while it is incomplete, demote balanced block(s) to
|
||||
// the thought channel, and keep rejecting blocks that never close.
|
||||
// The inline path must also run on the initial content delta: when the
|
||||
// first chunk already carries a complete opening tag (or a whole
|
||||
// balanced block), no pending candidate exists yet, and falling
|
||||
// through to the leaked-tag check would reject a valid shape that
|
||||
// depends only on stream chunking.
|
||||
const inlineOpeningTagCandidate =
|
||||
hasStructuredReasoning &&
|
||||
pendingTagCandidate &&
|
||||
!closingTagName &&
|
||||
confirmedOpeningTagCandidate;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue