From c7087eebdf77ee8ec873fa2ac214823155dd7fd9 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 10 Sep 2026 18:32:52 +0800 Subject: [PATCH 1/5] fix(core): keep Responses reasoning replay data off foreign wires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Part.thoughtSignature` doubles as the cross-provider store for reasoning replay data but carries no origin marker, so each wire interprets the same opaque string in its own way. Since #8169 landed, `responses-converter.ts` writes `JSON.stringify({ id, encrypted_content })` into that field for every Responses reasoning item. After a provider switch the Anthropic converter forwards any string-valued `thoughtSignature` as a native `thinking.signature`, and the Gemini wire passes it through untouched, so a Responses replay payload goes out as if it were another provider's native signature. Guard both request-build points with a shared recognizer for that payload shape, `isResponsesReasoningSignature` in `thoughtUtils.ts`. The Anthropic converter drops the payload and leaves the thinking block unsigned; the Gemini wire deletes it from the shallow copy that `stripPartFields` already builds, so the caller's history keeps the payload and a later switch back to Responses can still replay it. Both keep the visible reasoning text. This completes the fallback `responses-converter.ts` already applies in the other direction -- drop the unreplayable payload, preserve the human-readable summary, log the drop -- rather than adding a new heuristic. A native Anthropic or Gemini signature is an opaque token that never takes this JSON shape, so legitimate same-wire round-trips stay lossless; the tests assert both sides on both wires. Not addressed here: treating *unknown* metadata as incompatible by default. With no origin marker on the field, "unknown" can only be decided by guessing at string shapes, and guessing wrong drops legitimate native signatures. That needs the provenance contract discussed in #8533. Tests: packages/core converter.test.ts, llm-content-generator.test.ts and thoughtUtils.test.ts pass 158/158. Both new leak assertions fail before the guard with `expected '{"id":"rs_68c6c0c9ff5c8191a29b2e78c1a…' to be undefined`. Adjacent wire suites (responses-converter, anthropic generator, openai converter) pass 499/499. Fixes #9453 Co-authored-by: Qwen-Coder Patrol-Run: qwen-issue-patrol/jmtvd2hbyvd --- .../converter.test.ts | 78 ++++++++++++++++ .../anthropicContentGenerator/converter.ts | 20 +++- .../llm-content-generator.test.ts | 92 +++++++++++++++++++ .../llm-content-generator.ts | 12 +++ packages/core/src/utils/thoughtUtils.ts | 35 +++++++ 5 files changed, 235 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index fc6e3e4806a..91241a4ec1e 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -4421,4 +4421,82 @@ describe('AnthropicContentConverter', () => { }); }); }); + + // https://github.com/QwenLM/qwen-code/issues/9453 + // + // The OpenAI Responses generator stashes an opaque reasoning-replay payload + // in the shared `Part.thoughtSignature` field (responses-converter.ts: + // `encodeReasoningSignature({ id, encrypted_content })`). That payload is + // only meaningful to the Responses API; after a provider switch it must not + // reach the Anthropic wire as a native `thinking.signature`, while the + // visible reasoning summary is kept. + describe('cross-provider reasoning replay metadata', () => { + const responsesReplaySignature = JSON.stringify({ + id: 'rs_68c6c0c9ff5c8191a29b2e78c1a40c83', + encrypted_content: 'gAAAAABvcmVhc29uaW5nLXJlcGxheS1wYXlsb2Fk', + }); + + // A native Anthropic signature is an opaque token: it never starts with + // '{' and never parses as the Responses replay payload shape. + const anthropicNativeSignature = + 'EqQBCgIYAhIkAc6dE9c2eN8aBf1c5d7e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6E='; + + const buildRequest = (thoughtSignature: string) => ({ + model: 'models/test', + contents: [ + { role: 'user' as const, parts: [{ text: 'First' }] }, + { + role: 'model' as const, + parts: [ + { + text: 'Reasoning summary', + thought: true, + thoughtSignature, + }, + { text: 'Visible answer' }, + ], + }, + { role: 'user' as const, parts: [{ text: 'Second' }] }, + ], + }); + + it('forwards a native Anthropic thinking signature unchanged', () => { + const { messages } = converter.convertLlmRequestToAnthropic( + buildRequest(anthropicNativeSignature), + { enableCacheControl: false }, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'Reasoning summary', + signature: anthropicNativeSignature, + }, + { type: 'text', text: 'Visible answer' }, + ], + }); + }); + + it('does not forward a Responses replay payload as a native signature', () => { + const { messages } = converter.convertLlmRequestToAnthropic( + buildRequest(responsesReplaySignature), + { enableCacheControl: false }, + ); + + const blocks = messages[1]!.content as Array<{ + type: string; + thinking?: string; + signature?: string; + }>; + const thinkingBlock = blocks.find((b) => b.type === 'thinking'); + + // The foreign replay payload must not be sent as a native signature... + expect(thinkingBlock?.signature).toBeUndefined(); + // ...but the visible reasoning summary survives. + expect(thinkingBlock?.thinking).toBe('Reasoning summary'); + expect(blocks).toContainEqual({ type: 'text', text: 'Visible answer' }); + }); + }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index c4d38378149..9541467277a 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -27,6 +27,7 @@ import { } from '../../utils/schemaConverter.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { normalizeMcpToolName } from '../../utils/tool-name-utils.js'; +import { isResponsesReasoningSignature } from '../../utils/thoughtUtils.js'; type AnthropicMessageParam = Anthropic.MessageParam; // `scope: 'global'` is sent under the `prompt-caching-scope-2026-01-05` beta @@ -610,8 +611,23 @@ export class AnthropicContentConverter { 'thoughtSignature' in part && typeof part.thoughtSignature === 'string' ) { - (thinkingBlock as { signature?: string }).signature = - part.thoughtSignature; + // `thoughtSignature` carries no origin marker, so a Responses-API + // reasoning replay payload (`{"id":…,"encrypted_content":…}`) + // reaches here unchanged after a provider switch. It is not an + // Anthropic signature — forwarding it puts a foreign opaque blob + // on the wire as `thinking.signature`. Drop the payload and keep + // the visible reasoning text set above, mirroring the fallback + // `responses-converter.ts` already applies in the other direction + // for an unreplayable signature. + // https://github.com/QwenLM/qwen-code/issues/9453 + if (isResponsesReasoningSignature(part.thoughtSignature)) { + debugLogger.debug( + 'Dropping a Responses reasoning replay payload from thoughtSignature; keeping thinking text unsigned', + ); + } else { + (thinkingBlock as { signature?: string }).signature = + part.thoughtSignature; + } } contentBlocks.push(thinkingBlock as AnthropicContentBlockParam); } diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 2a629efc889..2bed819e1bf 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { LlmContentGenerator } from './llm-content-generator.js'; import { GoogleGenAI } from '@google/genai'; +import type { Part } from '@google/genai'; import type { Config } from '../../config/config.js'; const mockReportLlmRequest = vi.hoisted(() => vi.fn()); @@ -797,4 +798,95 @@ describe('LlmContentGenerator', () => { 'Unsupported media type for Gemini: video/mp4.', ); }); + + // https://github.com/QwenLM/qwen-code/issues/9453 + // + // The OpenAI Responses generator stashes an opaque reasoning-replay payload + // in the shared `Part.thoughtSignature` field. That payload is only + // meaningful to the Responses API, so after a provider switch it must not + // travel on the Gemini wire as if it were a Gemini-native signature — while + // the visible reasoning summary and `thought: true` marker are kept. + describe('cross-provider reasoning replay metadata', () => { + const responsesReplaySignature = JSON.stringify({ + id: 'rs_68c6c0c9ff5c8191a29b2e78c1a40c83', + encrypted_content: 'gAAAAABvcmVhc29uaW5nLXJlcGxheS1wYXlsb2Fk', + }); + + // A Gemini-native thoughtSignature is an opaque token: it never starts + // with '{' and never parses as the Responses replay payload shape. + const geminiNativeSignature = + 'Ck0BShsIxKq3wOa2tgUQ5LK0BhjOqrfA5ra2BRABGAIiQB9Z7xKq3wOa2tgU'; + + const buildRequest = (thoughtSignature: string) => ({ + model: 'gemini-2.5-pro', + contents: [ + { role: 'user' as const, parts: [{ text: 'First' }] }, + { + role: 'model' as const, + parts: [ + { text: 'Reasoning summary', thought: true, thoughtSignature }, + { text: 'Visible answer' }, + ], + }, + { role: 'user' as const, parts: [{ text: 'Second' }] }, + ], + }); + + it('preserves a native Gemini thoughtSignature', async () => { + await generator.generateContent( + buildRequest(geminiNativeSignature), + 'prompt-id', + ); + + const calledWith = + mockGoogleGenAI.models.generateContent.mock.calls[0][0]; + const thoughtPart = calledWith.contents[1].parts[0]; + + expect(thoughtPart.thoughtSignature).toBe(geminiNativeSignature); + expect(thoughtPart.thought).toBe(true); + expect(thoughtPart.text).toBe('Reasoning summary'); + }); + + it('strips a Responses replay payload but keeps the visible reasoning text', async () => { + await generator.generateContent( + buildRequest(responsesReplaySignature), + 'prompt-id', + ); + + const calledWith = + mockGoogleGenAI.models.generateContent.mock.calls[0][0]; + const thoughtPart = calledWith.contents[1].parts[0]; + + expect(thoughtPart.thoughtSignature).toBeUndefined(); + expect(thoughtPart.thought).toBe(true); + expect(thoughtPart.text).toBe('Reasoning summary'); + expect(calledWith.contents[1].parts[1].text).toBe('Visible answer'); + }); + + it('does not mutate the caller-owned history part', async () => { + // Hold the part by identity rather than re-deriving it from the request, + // so this asserts the caller's own object was not touched. + const historyPart: Part = { + text: 'Reasoning summary', + thought: true, + thoughtSignature: responsesReplaySignature, + }; + + await generator.generateContent( + { + model: 'gemini-2.5-pro', + contents: [ + { role: 'user', parts: [{ text: 'First' }] }, + { role: 'model', parts: [historyPart, { text: 'Visible answer' }] }, + { role: 'user', parts: [{ text: 'Second' }] }, + ], + }, + 'prompt-id', + ); + + // The strip is wire-only: persisted history keeps the payload so a + // later switch back to the Responses API can still replay it. + expect(historyPart.thoughtSignature).toBe(responsesReplaySignature); + }); + }); }); diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index b811296da5f..d5d65e657e5 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -34,6 +34,7 @@ import { hasDynamicPlaceholder, warnIfDynamicHeadersDisabled, } from '../outbound-dynamic-headers.js'; +import { isResponsesReasoningSignature } from '../../utils/thoughtUtils.js'; const debugLogger = createDebugLogger('GEMINI'); @@ -366,6 +367,17 @@ export class LlmContentGenerator implements ContentGenerator { result.fileData = fileDataWithoutDisplayName as Part['fileData']; } + // `thoughtSignature` carries no origin marker, so a Responses-API + // reasoning replay payload (`{"id":…,"encrypted_content":…}`) reaches the + // Gemini wire unchanged after a provider switch. It is not a Gemini-native + // signature, so drop it — wire-only, since `result` is a copy and the + // caller's history keeps the payload for a later switch back. `thought` + // and `text` are untouched, so the visible reasoning summary survives. + // https://github.com/QwenLM/qwen-code/issues/9453 + if (isResponsesReasoningSignature(result.thoughtSignature)) { + delete result.thoughtSignature; + } + // Handle functionResponse parts (which may contain nested media parts) // Convert unsupported media types (audio, video) to text for Gemini API if (result.functionResponse?.parts) { diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index 523d455694c..ad22924aba2 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -37,6 +37,41 @@ export function isOpenAIReasoningThoughtPart(part: Part): boolean { ); } +/** + * Recognizes the OpenAI Responses reasoning-replay payload that + * `openaiResponsesContentGenerator` stashes in the shared + * `Part.thoughtSignature` field (`JSON.stringify({ id, encrypted_content })`). + * + * `thoughtSignature` carries no origin marker, so every wire that reads it has + * to decide for itself whether the value is one of its own. A native Anthropic + * `thinking.signature` or Gemini `thoughtSignature` is an opaque token that + * never takes this JSON shape, so recognizing the payload is enough to keep it + * off a foreign wire without putting a legitimate native signature at risk. + * + * Mirrors the shape checks already in `llm-chat.ts` + * (`isCompleteResponsesReasoningSignature`) and `responses-converter.ts` + * (`decodeReasoningSignature`). + * See https://github.com/QwenLM/qwen-code/issues/9453 + */ +export function isResponsesReasoningSignature( + signature: string | undefined, +): boolean { + if (!signature || !signature.startsWith('{')) return false; + try { + const payload: unknown = JSON.parse(signature); + return ( + payload !== null && + typeof payload === 'object' && + 'id' in payload && + typeof payload.id === 'string' && + 'encrypted_content' in payload && + typeof payload.encrypted_content === 'string' + ); + } catch { + return false; + } +} + /** * Parses a raw thought string into a structured ThoughtSummary object. * From a1f7f68f76916fc29f0c0eab23bc1f947dfcdd6d Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 10 Sep 2026 23:42:57 +0800 Subject: [PATCH 2/5] fix(core): demote foreign Responses replay payload to plain text An unsigned `thinking` block is exactly the shape the downstream `dropUnsignedThinkingFromAssistantMessages` pass treats as a proxy protocol violation, so a foreign Responses reasoning replay payload that gets dropped must not leave an unsigned thinking block on the wire. Demote it to a plain text block (keeping the summary when present) instead, mirroring responses-converter.ts's fallback for unreplayable signatures. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtvoi040vv --- .../converter.test.ts | 120 +++++++++++++++++- .../anthropicContentGenerator/converter.ts | 22 +++- 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 91241a4ec1e..f645423ed64 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -4489,14 +4489,124 @@ describe('AnthropicContentConverter', () => { type: string; thinking?: string; signature?: string; + text?: string; }>; - const thinkingBlock = blocks.find((b) => b.type === 'thinking'); - // The foreign replay payload must not be sent as a native signature... - expect(thinkingBlock?.signature).toBeUndefined(); - // ...but the visible reasoning summary survives. - expect(thinkingBlock?.thinking).toBe('Reasoning summary'); + // The foreign replay payload must not be sent as a native signature, + // and no unsigned thinking block is emitted for it... + expect(blocks.some((b) => b.type === 'thinking')).toBe(false); + // ...but the visible reasoning summary survives as plain text. + expect(blocks).toContainEqual({ + type: 'text', + text: 'Reasoning summary', + }); expect(blocks).toContainEqual({ type: 'text', text: 'Visible answer' }); }); + + it('does not throw on an active tool-use turn when dropping the replay payload', () => { + const { messages } = converter.convertLlmRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user' as const, parts: [{ text: 'First' }] }, + { + role: 'model' as const, + parts: [ + { + text: 'Reasoning summary', + thought: true, + thoughtSignature: responsesReplaySignature, + }, + { + functionCall: { id: 'call-1', name: 'tool_name', args: {} }, + }, + ], + }, + { + role: 'user' as const, + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'tool_name', + response: { output: 'ok' }, + }, + }, + ], + }, + ], + }, + { dropUnsignedAssistantThinking: true }, + ); + + // Must not throw "proxy omitted the thinking signature": the replay + // payload is dropped rather than emitted as an unsigned thinking block, + // so dropUnsignedThinkingFromAssistantMessages never sees an unsigned + // thinking block on this active tool-use turn. + const assistant = messages.find((m) => m.role === 'assistant'); + const blocks = Array.isArray(assistant?.content) + ? assistant!.content + : []; + expect(blocks.some((b) => b.type === 'tool_use')).toBe(true); + }); + + it('drops a signature-only replay payload without emitting a thinking block', () => { + // flushThoughtEpisode always sets `text` (to '' for a signature-only + // episode), so the shape reaching this converter is an empty-text + // thought part, not a part with no `text` key. + const signatureOnlyPart = { + text: '', + thought: true, + thoughtSignature: responsesReplaySignature, + }; + + const build = (isLatestTurn: boolean) => ({ + model: 'models/test', + contents: [ + { role: 'user' as const, parts: [{ text: 'First' }] }, + { + role: 'model' as const, + parts: [signatureOnlyPart, { text: 'Visible answer' }], + }, + ...(isLatestTurn + ? [] + : [{ role: 'user' as const, parts: [{ text: 'Second' }] }]), + ], + }); + + const assertShape = (result: { + messages: Array<{ role: string; content: unknown }>; + }) => { + const assistant = result.messages.find((m) => m.role === 'assistant'); + expect(assistant?.content).toEqual([ + { type: 'text', text: 'Visible answer' }, + ]); + }; + + // Latest-turn and non-latest-turn positions, under both the production + // proxy option set (dropUnsignedAssistantThinking) and the bare option + // set. The replay payload must never surface as a thinking block or a + // signature in any of them. + assertShape( + converter.convertLlmRequestToAnthropic(build(false), { + dropUnsignedAssistantThinking: true, + }), + ); + assertShape( + converter.convertLlmRequestToAnthropic(build(true), { + dropUnsignedAssistantThinking: true, + }), + ); + assertShape( + converter.convertLlmRequestToAnthropic(build(false), { + enableCacheControl: false, + }), + ); + assertShape( + converter.convertLlmRequestToAnthropic(build(true), { + enableCacheControl: false, + }), + ); + }); }); }); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 9541467277a..3198bbe4af3 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -607,6 +607,7 @@ export class AnthropicContentConverter { type: 'thinking', thinking: part.text || '', }; + let dropThinkingBlock = false; if ( 'thoughtSignature' in part && typeof part.thoughtSignature === 'string' @@ -615,21 +616,30 @@ export class AnthropicContentConverter { // reasoning replay payload (`{"id":…,"encrypted_content":…}`) // reaches here unchanged after a provider switch. It is not an // Anthropic signature — forwarding it puts a foreign opaque blob - // on the wire as `thinking.signature`. Drop the payload and keep - // the visible reasoning text set above, mirroring the fallback - // `responses-converter.ts` already applies in the other direction - // for an unreplayable signature. + // on the wire as `thinking.signature`. Drop the payload instead, + // mirroring the fallback `responses-converter.ts` already applies + // in the other direction for an unreplayable signature. // https://github.com/QwenLM/qwen-code/issues/9453 if (isResponsesReasoningSignature(part.thoughtSignature)) { debugLogger.debug( - 'Dropping a Responses reasoning replay payload from thoughtSignature; keeping thinking text unsigned', + 'Dropping a Responses reasoning replay payload from thoughtSignature', ); + // An unsigned `thinking` block is exactly the shape the passes + // below treat as a proxy protocol violation + // (`dropUnsignedThinkingFromAssistantMessages`), so do not emit + // one. Keep the visible summary as plain text when present. + dropThinkingBlock = true; + if (part.text) { + contentBlocks.push({ type: 'text', text: part.text }); + } } else { (thinkingBlock as { signature?: string }).signature = part.thoughtSignature; } } - contentBlocks.push(thinkingBlock as AnthropicContentBlockParam); + if (!dropThinkingBlock) { + contentBlocks.push(thinkingBlock as AnthropicContentBlockParam); + } } } From d9baae67e83638cda7475c4c2992cd3410215fd7 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 11 Sep 2026 05:13:12 +0800 Subject: [PATCH 3/5] fix(core): gate Responses replay demotion on dropUnsignedAssistantThinking Demoting a foreign Responses reasoning replay payload to a visible text block leaked hidden reasoning as assistant prose under stripAssistantThinking (DeepSeek + thinking disabled), because stripThinkingFromAssistantMessages only removes thinking blocks. Thread dropUnsignedAssistantThinking into processContents/processContent and take the text-demotion branch only when it is set; otherwise emit the thinking block unsigned so strip/normalize passes handle it instead of ever attaching the foreign payload as a native signature. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtvz7syqwc --- .../converter.test.ts | 79 +++++++++++++------ .../anthropicContentGenerator/converter.ts | 33 +++++--- 2 files changed, 78 insertions(+), 34 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index f645423ed64..2b79e338793 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -4485,22 +4485,32 @@ describe('AnthropicContentConverter', () => { { enableCacheControl: false }, ); - const blocks = messages[1]!.content as Array<{ - type: string; - thinking?: string; - signature?: string; - text?: string; - }>; + // The foreign replay payload must not be attached as a native + // `signature`: the thinking block is emitted unsigned (no `signature` + // key), leaving the summary text intact for the downstream + // strip/normalize passes to decide its fate. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'Reasoning summary' }, + { type: 'text', text: 'Visible answer' }, + ], + }); + }); - // The foreign replay payload must not be sent as a native signature, - // and no unsigned thinking block is emitted for it... - expect(blocks.some((b) => b.type === 'thinking')).toBe(false); - // ...but the visible reasoning summary survives as plain text. - expect(blocks).toContainEqual({ - type: 'text', - text: 'Reasoning summary', + it('strips the foreign replay payload under stripAssistantThinking', () => { + const { messages } = converter.convertLlmRequestToAnthropic( + buildRequest(responsesReplaySignature), + { stripAssistantThinking: true, enableCacheControl: false }, + ); + + // The hidden reasoning must not leak as visible assistant prose: under + // stripAssistantThinking the unsigned thinking block is removed, and no + // demoted `'Reasoning summary'` text block is emitted. + expect(messages[1]).toEqual({ + role: 'assistant', + content: [{ type: 'text', text: 'Visible answer' }], }); - expect(blocks).toContainEqual({ type: 'text', text: 'Visible answer' }); }); it('does not throw on an active tool-use turn when dropping the replay payload', () => { @@ -4548,9 +4558,14 @@ describe('AnthropicContentConverter', () => { ? assistant!.content : []; expect(blocks.some((b) => b.type === 'tool_use')).toBe(true); + // ...and the demoted reasoning summary survives as plain text. + expect(blocks).toContainEqual({ + type: 'text', + text: 'Reasoning summary', + }); }); - it('drops a signature-only replay payload without emitting a thinking block', () => { + it('never forwards a signature-only replay payload as a native signature', () => { // flushThoughtEpisode always sets `text` (to '' for a signature-only // episode), so the shape reaching this converter is an empty-text // thought part, not a part with no `text` key. @@ -4574,35 +4589,51 @@ describe('AnthropicContentConverter', () => { ], }); - const assertShape = (result: { + const findAssistant = (result: { + messages: Array<{ role: string; content: unknown }>; + }) => result.messages.find((m) => m.role === 'assistant'); + + // Under dropUnsignedAssistantThinking the replay payload is dropped and + // no thinking block is emitted. + const assertDropped = (result: { + messages: Array<{ role: string; content: unknown }>; + }) => { + expect(findAssistant(result)?.content).toEqual([ + { type: 'text', text: 'Visible answer' }, + ]); + }; + + // Under the bare option set the block is kept but left unsigned (no + // `signature` key), so the foreign payload still never reaches the wire. + const assertUnsigned = (result: { messages: Array<{ role: string; content: unknown }>; }) => { - const assistant = result.messages.find((m) => m.role === 'assistant'); - expect(assistant?.content).toEqual([ + expect(findAssistant(result)?.content).toEqual([ + { type: 'thinking', thinking: '' }, { type: 'text', text: 'Visible answer' }, ]); }; // Latest-turn and non-latest-turn positions, under both the production // proxy option set (dropUnsignedAssistantThinking) and the bare option - // set. The replay payload must never surface as a thinking block or a - // signature in any of them. - assertShape( + // set. The replay payload must never surface as a native signature in + // any of them. + assertDropped( converter.convertLlmRequestToAnthropic(build(false), { dropUnsignedAssistantThinking: true, }), ); - assertShape( + assertDropped( converter.convertLlmRequestToAnthropic(build(true), { dropUnsignedAssistantThinking: true, }), ); - assertShape( + assertUnsigned( converter.convertLlmRequestToAnthropic(build(false), { enableCacheControl: false, }), ); - assertShape( + assertUnsigned( converter.convertLlmRequestToAnthropic(build(true), { enableCacheControl: false, }), diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 3198bbe4af3..a45afabb82e 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -265,7 +265,11 @@ export class AnthropicContentConverter { request.config?.systemInstruction, ); - this.processContents(request.contents, messages); + this.processContents( + request.contents, + messages, + !!options.dropUnsignedAssistantThinking, + ); if (options.stripAssistantThinking) { this.stripThinkingFromAssistantMessages(messages); @@ -568,19 +572,21 @@ export class AnthropicContentConverter { private processContents( contents: ContentListUnion, messages: AnthropicMessageParam[], + demoteForeignThoughtToText: boolean, ): void { if (Array.isArray(contents)) { for (const content of contents) { - this.processContent(content, messages); + this.processContent(content, messages, demoteForeignThoughtToText); } } else if (contents) { - this.processContent(contents, messages); + this.processContent(contents, messages, demoteForeignThoughtToText); } } private processContent( content: ContentUnion | PartUnion, messages: AnthropicMessageParam[], + demoteForeignThoughtToText: boolean, ): void { if (typeof content === 'string') { messages.push({ @@ -624,13 +630,20 @@ export class AnthropicContentConverter { debugLogger.debug( 'Dropping a Responses reasoning replay payload from thoughtSignature', ); - // An unsigned `thinking` block is exactly the shape the passes - // below treat as a proxy protocol violation - // (`dropUnsignedThinkingFromAssistantMessages`), so do not emit - // one. Keep the visible summary as plain text when present. - dropThinkingBlock = true; - if (part.text) { - contentBlocks.push({ type: 'text', text: part.text }); + // When the caller asked to drop unsigned thinking + // (`dropUnsignedAssistantThinking`), an unsigned `thinking` + // block is exactly the shape the pass below treats as a proxy + // protocol violation, so do not emit one — keep the visible + // summary as plain text when present. Otherwise leave the block + // unsigned (never attach the foreign payload as a signature) so + // `stripThinkingFromAssistantMessages` removes it under + // `stripAssistantThinking` and `fillMissingThinkingSignatures` + // fills `signature: ''` under DeepSeek normalization. + if (demoteForeignThoughtToText) { + dropThinkingBlock = true; + if (part.text) { + contentBlocks.push({ type: 'text', text: part.text }); + } } } else { (thinkingBlock as { signature?: string }).signature = From 12fdfc6a024831e676e608d3e773e9834208b401 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 11 Sep 2026 11:49:44 +0800 Subject: [PATCH 4/5] fix(core): harden Responses reasoning signature recognizer and tighten tests - Guard isResponsesReasoningSignature against non-string input so a Gemini signature_delta number/boolean no longer throws startsWith and crashes. - Tolerate leading whitespace in the '{' pre-check (matching JSON.parse) so whitespace-prefixed replay payloads are still recognized and kept off wires. - Pin the Anthropic demote/tool-use blocks with exact toEqual and add a non-empty summary demote assertion to distinguish demote from drop. Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtwe7xfux1 --- .../converter.test.ts | 56 +++++++++++++++---- .../llm-content-generator.test.ts | 40 +++++++++++++ packages/core/src/utils/thoughtUtils.test.ts | 53 ++++++++++++++++++ packages/core/src/utils/thoughtUtils.ts | 7 +-- 4 files changed, 141 insertions(+), 15 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 2b79e338793..8d5177af95f 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -4552,17 +4552,13 @@ describe('AnthropicContentConverter', () => { // Must not throw "proxy omitted the thinking signature": the replay // payload is dropped rather than emitted as an unsigned thinking block, // so dropUnsignedThinkingFromAssistantMessages never sees an unsigned - // thinking block on this active tool-use turn. + // thinking block on this active tool-use turn. The demoted reasoning + // summary survives as plain text, and the tool_use block is untouched. const assistant = messages.find((m) => m.role === 'assistant'); - const blocks = Array.isArray(assistant?.content) - ? assistant!.content - : []; - expect(blocks.some((b) => b.type === 'tool_use')).toBe(true); - // ...and the demoted reasoning summary survives as plain text. - expect(blocks).toContainEqual({ - type: 'text', - text: 'Reasoning summary', - }); + expect(assistant?.content).toEqual([ + { type: 'text', text: 'Reasoning summary' }, + { type: 'tool_use', id: 'call-1', name: 'tool_name', input: {} }, + ]); }); it('never forwards a signature-only replay payload as a native signature', () => { @@ -4594,7 +4590,9 @@ describe('AnthropicContentConverter', () => { }) => result.messages.find((m) => m.role === 'assistant'); // Under dropUnsignedAssistantThinking the replay payload is dropped and - // no thinking block is emitted. + // no thinking block is emitted. With empty (signature-only) text there + // is nothing to demote, so the exact content is just the visible answer + // — pinning that no empty-text block leaks through. const assertDropped = (result: { messages: Array<{ role: string; content: unknown }>; }) => { @@ -4639,5 +4637,41 @@ describe('AnthropicContentConverter', () => { }), ); }); + + it('demotes a non-empty replay summary to plain text instead of dropping it', () => { + // The empty-text signature-only case above cannot tell "demoted to + // text" apart from "dropped entirely", because with no text there is + // nothing to preserve. A non-empty summary pins the demote path: under + // dropUnsignedAssistantThinking the thinking block is dropped AND the + // visible summary survives as a plain-text block. + const { messages } = converter.convertLlmRequestToAnthropic( + { + model: 'models/test', + contents: [ + { role: 'user' as const, parts: [{ text: 'First' }] }, + { + role: 'model' as const, + parts: [ + { + text: 'Reasoning summary', + thought: true, + thoughtSignature: responsesReplaySignature, + }, + { text: 'Visible answer' }, + ], + }, + ], + }, + { dropUnsignedAssistantThinking: true }, + ); + + expect(messages[1]).toEqual({ + role: 'assistant', + content: [ + { type: 'text', text: 'Reasoning summary' }, + { type: 'text', text: 'Visible answer' }, + ], + }); + }); }); }); diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 2bed819e1bf..a336585bdb5 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -888,5 +888,45 @@ describe('LlmContentGenerator', () => { // later switch back to the Responses API can still replay it. expect(historyPart.thoughtSignature).toBe(responsesReplaySignature); }); + + it('forwards a non-string thoughtSignature without throwing', async () => { + // Gemini `signature_delta` can surface a non-string thoughtSignature on + // a history part (number/boolean). The recognizer must not crash on it + // with `startsWith is not a function` — it should treat it as a native + // opaque token and forward it unchanged, matching the base behavior. + const nonStringSignature = 1 as unknown as string; + + await generator.generateContent( + { + model: 'gemini-2.5-pro', + contents: [ + { role: 'user' as const, parts: [{ text: 'First' }] }, + { + role: 'model' as const, + parts: [ + { + text: 'Reasoning summary', + thought: true, + thoughtSignature: nonStringSignature, + }, + { text: 'Visible answer' }, + ], + }, + { role: 'user' as const, parts: [{ text: 'Second' }] }, + ], + }, + 'prompt-id', + ); + + const calledWith = + mockGoogleGenAI.models.generateContent.mock.calls[0][0]; + const thoughtPart = calledWith.contents[1].parts[0]; + + // The garbage is forwarded unchanged (base behavior): the recognizer + // only drops the Responses replay payload shape, never crashes. + expect(thoughtPart.thoughtSignature).toBe(nonStringSignature); + expect(thoughtPart.thought).toBe(true); + expect(thoughtPart.text).toBe('Reasoning summary'); + }); }); }); diff --git a/packages/core/src/utils/thoughtUtils.test.ts b/packages/core/src/utils/thoughtUtils.test.ts index c9b82a1907e..73a7f499e0c 100644 --- a/packages/core/src/utils/thoughtUtils.test.ts +++ b/packages/core/src/utils/thoughtUtils.test.ts @@ -9,6 +9,7 @@ import type { GenerateContentResponse, Part } from '@google/genai'; import { createOpenAIReasoningThoughtPart, getThoughtSummary, + isResponsesReasoningSignature, parseThought, } from './thoughtUtils.js'; @@ -132,3 +133,55 @@ describe('getThoughtSummary', () => { expect(getThoughtSummary(response)).toBeNull(); }); }); + +describe('isResponsesReasoningSignature', () => { + const replayPayload = JSON.stringify({ + id: 'rs_68c6c0c9ff5c8191a29b2e78c1a40c83', + encrypted_content: 'gAAAAABvcmVhc29uaW5nLXJlcGxheS1wYXlsb2Fk', + }); + + it('recognizes a Responses reasoning replay payload', () => { + expect(isResponsesReasoningSignature(replayPayload)).toBe(true); + }); + + it('recognizes a payload with leading whitespace', () => { + // The `startsWith('{')` pre-check must tolerate leading whitespace the + // same way `JSON.parse` itself does, so a payload preceded by a newline + // or spaces is still recognized (and dropped off a foreign wire) rather + // than forwarded unchanged. + expect(isResponsesReasoningSignature(`\n ${replayPayload}`)).toBe(true); + }); + + it('rejects a non-string id', () => { + const nonStringId = JSON.stringify({ + id: 123, + encrypted_content: 'gAAAAABvcmVhc29uaW5nLXJlcGxheS1wYXlsb2Fk', + }); + expect(isResponsesReasoningSignature(nonStringId)).toBe(false); + }); + + it('rejects non-string input without throwing', () => { + // Gemini `signature_delta` can surface a number/boolean on a history part; + // the recognizer must not throw `startsWith` on a non-string. + expect(isResponsesReasoningSignature(undefined)).toBe(false); + expect(isResponsesReasoningSignature(null)).toBe(false); + expect(isResponsesReasoningSignature(1)).toBe(false); + expect(isResponsesReasoningSignature(true)).toBe(false); + expect(isResponsesReasoningSignature({})).toBe(false); + }); + + it('rejects a non-object parsed payload', () => { + expect(isResponsesReasoningSignature('"just a string"')).toBe(false); + expect(isResponsesReasoningSignature('123')).toBe(false); + }); + + it('rejects an object missing the replay shape', () => { + expect(isResponsesReasoningSignature('{}')).toBe(false); + expect(isResponsesReasoningSignature(JSON.stringify({ id: 'rs_1' }))).toBe( + false, + ); + expect( + isResponsesReasoningSignature(JSON.stringify({ encrypted_content: 'x' })), + ).toBe(false); + }); +}); diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index ad22924aba2..27667a65ca0 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -53,10 +53,9 @@ export function isOpenAIReasoningThoughtPart(part: Part): boolean { * (`decodeReasoningSignature`). * See https://github.com/QwenLM/qwen-code/issues/9453 */ -export function isResponsesReasoningSignature( - signature: string | undefined, -): boolean { - if (!signature || !signature.startsWith('{')) return false; +export function isResponsesReasoningSignature(signature: unknown): boolean { + if (typeof signature !== 'string') return false; + if (!signature.trimStart().startsWith('{')) return false; try { const payload: unknown = JSON.parse(signature); return ( From 11b347100ce92d653931da3382992ca6e335a268 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Fri, 11 Sep 2026 14:46:45 +0800 Subject: [PATCH 5/5] fix(core): close review gaps in reasoning signature tests and comments Co-authored-by: Qwen-Coder Patrol-Run: qwen-pr-closeout/jmtwknetexb --- .../converter.test.ts | 25 ++++++++++++++++--- .../llm-content-generator.test.ts | 7 +++--- packages/core/src/utils/thoughtUtils.test.ts | 5 ++-- packages/core/src/utils/thoughtUtils.ts | 7 +++--- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 8d5177af95f..441d45d262b 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -4581,7 +4581,10 @@ describe('AnthropicContentConverter', () => { }, ...(isLatestTurn ? [] - : [{ role: 'user' as const, parts: [{ text: 'Second' }] }]), + : [ + { role: 'user' as const, parts: [{ text: 'Second' }] }, + { role: 'model' as const, parts: [{ text: 'Later answer' }] }, + ]), ], }); @@ -4601,8 +4604,10 @@ describe('AnthropicContentConverter', () => { ]); }; - // Under the bare option set the block is kept but left unsigned (no - // `signature` key), so the foreign payload still never reaches the wire. + // Under the bare option set on the LATEST turn the empty-text block is + // kept but left unsigned (no `signature` key), so the foreign payload + // still never reaches the wire — the latest turn's signatures must + // replay byte-exact, so dropEmptyTextThinkingBlocks leaves it. const assertUnsigned = (result: { messages: Array<{ role: string; content: unknown }>; }) => { @@ -4612,6 +4617,18 @@ describe('AnthropicContentConverter', () => { ]); }; + // Under the bare option set on a NON-latest turn, dropEmptyTextThinkingBlocks + // deletes the empty-text thinking block, leaving only the visible answer. + // This is the assertion that only a genuine second (later) model turn can + // reach: with today's single-model-turn fixture the block would survive. + const assertNonLatestThinkingDropped = (result: { + messages: Array<{ role: string; content: unknown }>; + }) => { + expect(findAssistant(result)?.content).toEqual([ + { type: 'text', text: 'Visible answer' }, + ]); + }; + // Latest-turn and non-latest-turn positions, under both the production // proxy option set (dropUnsignedAssistantThinking) and the bare option // set. The replay payload must never surface as a native signature in @@ -4626,7 +4643,7 @@ describe('AnthropicContentConverter', () => { dropUnsignedAssistantThinking: true, }), ); - assertUnsigned( + assertNonLatestThinkingDropped( converter.convertLlmRequestToAnthropic(build(false), { enableCacheControl: false, }), diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index a336585bdb5..b7ef3318458 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -890,10 +890,9 @@ describe('LlmContentGenerator', () => { }); it('forwards a non-string thoughtSignature without throwing', async () => { - // Gemini `signature_delta` can surface a non-string thoughtSignature on - // a history part (number/boolean). The recognizer must not crash on it - // with `startsWith is not a function` — it should treat it as a native - // opaque token and forward it unchanged, matching the base behavior. + // The SDK types thoughtSignature as string, but the value crosses untyped + // boundaries — persisted-history restore performs no Part shape validation — + // so treat a non-string as a native opaque token rather than throwing. const nonStringSignature = 1 as unknown as string; await generator.generateContent( diff --git a/packages/core/src/utils/thoughtUtils.test.ts b/packages/core/src/utils/thoughtUtils.test.ts index 73a7f499e0c..04c385e92bd 100644 --- a/packages/core/src/utils/thoughtUtils.test.ts +++ b/packages/core/src/utils/thoughtUtils.test.ts @@ -161,8 +161,9 @@ describe('isResponsesReasoningSignature', () => { }); it('rejects non-string input without throwing', () => { - // Gemini `signature_delta` can surface a number/boolean on a history part; - // the recognizer must not throw `startsWith` on a non-string. + // The SDK types thoughtSignature as string, but the value crosses untyped + // boundaries — persisted-history restore performs no Part shape validation — + // so treat a non-string as a native opaque token rather than throwing. expect(isResponsesReasoningSignature(undefined)).toBe(false); expect(isResponsesReasoningSignature(null)).toBe(false); expect(isResponsesReasoningSignature(1)).toBe(false); diff --git a/packages/core/src/utils/thoughtUtils.ts b/packages/core/src/utils/thoughtUtils.ts index 27667a65ca0..52ef4d65e3a 100644 --- a/packages/core/src/utils/thoughtUtils.ts +++ b/packages/core/src/utils/thoughtUtils.ts @@ -48,9 +48,10 @@ export function isOpenAIReasoningThoughtPart(part: Part): boolean { * never takes this JSON shape, so recognizing the payload is enough to keep it * off a foreign wire without putting a legitimate native signature at risk. * - * Mirrors the shape checks already in `llm-chat.ts` - * (`isCompleteResponsesReasoningSignature`) and `responses-converter.ts` - * (`decodeReasoningSignature`). + * Mirrors `decodeReasoningSignature` in `responses-converter.ts`, which also + * tolerates leading whitespace. `llm-chat.ts`'s + * `isCompleteResponsesReasoningSignature` is the same check without that + * tolerance. * See https://github.com/QwenLM/qwen-code/issues/9453 */ export function isResponsesReasoningSignature(signature: unknown): boolean {