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:
jinjing.zzj 2026-08-21 04:59:18 +08:00
parent a3f1b2498a
commit 3cf2952eff
2 changed files with 60 additions and 1 deletions

View file

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

View file

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