diff --git a/src/bridge.ts b/src/bridge.ts index 45f046f424..33b88c19db 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -388,22 +388,18 @@ export function bridgeToResponsesSSE( let currentRawReasoning: { itemId: string; outputIndex: number; text: string; textBytes: number } | null = null; // Anthropic extended-thinking round-trip state: the signature signs the CURRENT thinking // block; redacted blocks are opaque payloads replayed verbatim. Attached to the reasoning - // item as an ocxr1 encrypted_content envelope on close. hiddenThinkingText collects the - // suppressed text under hideThinkingSummary so the signed text still round-trips. + // item as an ocxr1 encrypted_content envelope on close. Suppressed thinking text is never + // included because this envelope is only an encoding, not encryption. let pendingSignature: string | undefined; let pendingSignatureBytes = 0; let pendingRedacted: string[] = []; - let hiddenThinkingText = ""; - let hiddenThinkingBytes = 0; - const takeReasoningEnvelope = (hiddenText?: string): string | undefined => { + const takeReasoningEnvelope = (): string | undefined => { if (!pendingSignature && pendingRedacted.length === 0) return undefined; const envelope: ReasoningEnvelope = {}; if (pendingSignature) envelope.sig = pendingSignature; if (pendingRedacted.length > 0) envelope.red = pendingRedacted; - if (hiddenText) envelope.txt = hiddenText; const previousBytes = pendingSignatureBytes - + pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0) - + (hiddenText ? hiddenThinkingBytes : 0); + + pendingRedacted.reduce((sum, value) => sum + bytesOf(value), 0); const encoded = encodeReasoningEnvelope(envelope); const reservation = budget?.reserveTransient(bytesOf(encoded), { kind: "reasoning" }); pendingSignature = undefined; @@ -413,12 +409,9 @@ export function bridgeToResponsesSSE( budget?.releaseRetained(previousBytes, { kind: "reasoning" }); return encoded; }; - // hideThinkingSummary path: no visible reasoning item exists, but a signed thinking block - // must still round-trip — emit an envelope-only reasoning item (empty summary, no text leak). + // Preserve opaque provider state for hidden reasoning without exposing the suppressed text. const flushHiddenReasoningEnvelope = () => { - const encrypted = takeReasoningEnvelope(hiddenThinkingText || undefined); - hiddenThinkingText = ""; - hiddenThinkingBytes = 0; + const encrypted = takeReasoningEnvelope(); if (!encrypted) return; const itemId = `rs_${uuid()}`; const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted }; @@ -427,35 +420,20 @@ export function bridgeToResponsesSSE( retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning"); outputIndex++; }; - // hideThinkingSummary for RAW reasoning (openai-chat reasoning_content, kiro tags): no - // visible reasoning item is emitted — the app renders nothing, so tool cells keep grouping - // like native models — but the text still round-trips in a txt-only ocxr1 envelope so - // preserveReasoningContentModels replay (GLM interleaved thinking) keeps working. Direct - // encodeReasoningEnvelope: takeReasoningEnvelope's sig/red guard would drop txt-only. - let hiddenRawReasoningText = ""; - let hiddenRawReasoningBytes = 0; // Raw reasoning text flushed most recently, waiting for the tool call it // preceded. Recorded into the replay cache on tool_call_start so a later // continuation can re-attach it when history lost the reasoning item // (issue #950). Kept until new reasoning/text arrives: parallel tool // calls share the same preceding reasoning block. + let hiddenRawReasoningText = ""; + let hiddenRawReasoningBytes = 0; let rawReasoningForNextToolCall = ""; const flushHiddenRawReasoning = () => { if (!hiddenRawReasoningText) return; rawReasoningForNextToolCall = hiddenRawReasoningText; - const previousBytes = hiddenRawReasoningBytes; - const encrypted = encodeReasoningEnvelope({ txt: hiddenRawReasoningText }); - const reservation = budget?.reserveTransient(bytesOf(encrypted), { kind: "reasoning" }); + budget?.releaseRetained(hiddenRawReasoningBytes, { kind: "reasoning" }); hiddenRawReasoningText = ""; hiddenRawReasoningBytes = 0; - reservation?.commitRetained(); - budget?.releaseRetained(previousBytes, { kind: "reasoning" }); - const itemId = `rs_${uuid()}`; - const item = { type: "reasoning", id: itemId, summary: [] as never[], encrypted_content: encrypted }; - emit("response.output_item.added", { output_index: outputIndex, item }); - emit("response.output_item.done", { output_index: outputIndex, item }); - retainFinishedItem(item as OutputItem, bytesOf(encrypted), "reasoning"); - outputIndex++; }; // Kiro reasoning round-trip. Kiro sends its encrypted blob at the END of a turn, while the // assistant message is still open, so this CANNOT emit on arrival: the open message still @@ -869,12 +847,6 @@ export function bridgeToResponsesSSE( // recorded for a LATER tool call (CodeRabbit on #971). flushHiddenRawReasoning(); rawReasoningForNextToolCall = ""; - ({ value: hiddenThinkingText, bytes: hiddenThinkingBytes } = appendString( - hiddenThinkingText, - hiddenThinkingBytes, - event.thinking, - "reasoning", - )); break; } if (currentMsg) closeCurrentMessage("commentary"); @@ -1470,8 +1442,7 @@ function buildResponseJSONWithBudget( if (batchSignature) envelope.sig = batchSignature; if (batchRedacted.length > 0) envelope.red = batchRedacted; const hidden = options?.hideThinkingSummary === true; - if (hidden && currentSummaryReasoning && (envelope.sig || envelope.red)) envelope.txt = currentSummaryReasoning; - const encrypted = envelope.sig || envelope.red || envelope.txt ? encodeReasoningEnvelope(envelope) : undefined; + const encrypted = envelope.sig || envelope.red ? encodeReasoningEnvelope(envelope) : undefined; const sourceBytes = currentSummaryReasoningBytes + batchSignatureBytes + batchRedactedBytes; batchSignature = undefined; batchSignatureBytes = 0; @@ -1496,11 +1467,7 @@ function buildResponseJSONWithBudget( if (!currentRawReasoning) return; rawReasoningForNextToolCall = currentRawReasoning; if (options?.hideThinkingSummary === true) { - // Same contract as the streaming path: no visible reasoning, txt-only envelope round-trip. - pushOutput({ - type: "reasoning", id: `rs_${uuid()}`, summary: [], - encrypted_content: encodeReasoningEnvelope({ txt: currentRawReasoning }), - }, currentRawReasoningBytes, "reasoning"); + budget?.releaseRetained(currentRawReasoningBytes, { kind: "reasoning" }); currentRawReasoning = ""; currentRawReasoningBytes = 0; return; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index a06cd8aabc..f1bdd579eb 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -442,7 +442,7 @@ export function parseRequest(body: unknown): OcxParsedRequest { const envelope = typeof reasoning.encrypted_content === "string" ? decodeReasoningEnvelope(reasoning.encrypted_content) : null; - const thinkingText = envelope?.txt || text; + const thinkingText = text; // Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider // state for the assistant turn that ALREADY closed, because Kiro emits its diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 1735f775fb..96fb213ed3 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -19,11 +19,6 @@ export interface ReasoningEnvelope { sig?: string; /** Raw redacted_thinking block data payloads, order preserved. */ red?: string[]; - /** - * Hidden thinking text (hideThinkingSummary providers): the signature signs this exact text, - * so replay needs it even though the visible summary was suppressed. - */ - txt?: string; /** * Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to * the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve @@ -49,11 +44,9 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve const red = obj.red.filter((r): r is string => typeof r === "string"); if (red.length > 0) envelope.red = red; } - const txt = (parsed as { txt?: unknown }).txt; - if (typeof txt === "string" && txt.length > 0) envelope.txt = txt; const krc = (parsed as { krc?: unknown }).krc; if (typeof krc === "string" && krc.length > 0) envelope.krc = krc; - return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null; + return envelope.sig || envelope.red || envelope.krc ? envelope : null; } catch { return null; } diff --git a/tests/anthropic-thinking-signature.test.ts b/tests/anthropic-thinking-signature.test.ts index e1227e33ab..164aa840d2 100644 --- a/tests/anthropic-thinking-signature.test.ts +++ b/tests/anthropic-thinking-signature.test.ts @@ -108,7 +108,7 @@ describe("bridge ocxr1 envelope emission", () => { expect(env?.sig).toBe("RealSig1234567890=="); }); - test("SSE hideThinkingSummary: envelope-only reasoning item, no text leak", async () => { + test("SSE hideThinkingSummary: envelope preserves signature without hidden text", async () => { async function* gen() { yield* baseEvents; } const sse = await drainSse(bridgeToResponsesSSE(gen(), "claude-x", undefined, undefined, undefined, undefined, 2000, { hideThinkingSummary: true })); const reasoning = sseItems(sse).find(i => i.type === "reasoning"); @@ -118,7 +118,7 @@ describe("bridge ocxr1 envelope emission", () => { expect(sse.split("reasoning_summary_text.delta").length).toBe(1); // no summary deltas emitted const env = decodeReasoningEnvelope(reasoning!.encrypted_content as string); expect(env?.sig).toBe("RealSig1234567890=="); - expect(env?.txt).toBe("hidden chain"); // signed text survives inside the envelope only + expect(Buffer.from((reasoning!.encrypted_content as string).slice(OCX_REASONING_PREFIX.length), "base64").toString()).not.toContain("hidden chain"); }); test("JSON: reasoning item carries envelope; redacted blocks included", async () => { @@ -199,8 +199,11 @@ describe("parser ocxr1 decode + anthropic replay", () => { expect((assistant as { kiroRedactedReasoning?: string }).kiroRedactedReasoning).toBeUndefined(); }); - test("hidden signed text (txt) is restored as the thinking body", async () => { - const encrypted = encodeReasoningEnvelope({ sig: "RealSig1234567890==", txt: "the hidden signed text" }); + test("legacy plaintext envelope text is not restored as thinking", async () => { + const encrypted = OCX_REASONING_PREFIX + Buffer.from(JSON.stringify({ + sig: "RealSig1234567890==", + txt: "the hidden signed text", + })).toString("base64"); const parsed = parseRequest({ model: "anthropic/claude-x", input: [ @@ -211,7 +214,7 @@ describe("parser ocxr1 decode + anthropic replay", () => { }); const assistant = parsed.context.messages.find(m => m.role === "assistant"); const thinking = (assistant as unknown as { content: OcxThinkingContent[] }).content.find(p => p.type === "thinking"); - expect(thinking?.thinking).toBe("the hidden signed text"); + expect(thinking).toBeUndefined(); }); test("native (non-ocxr1) encrypted_content keeps the placeholder signature", async () => { @@ -261,17 +264,15 @@ describe("parser ocxr1 decode + anthropic replay", () => { const adapter = createAnthropicAdapter(provider); const firstEnvelope = encodeReasoningEnvelope({ sig: "FirstRealSignature123456==", - txt: "first signed chain", }); const secondEnvelope = encodeReasoningEnvelope({ sig: "SecondRealSignature123456==", - txt: "second signed chain", }); const parsed = parseRequest({ model: "anthropic/claude-x", input: [ - { type: "reasoning", id: "rs_first", summary: [], encrypted_content: firstEnvelope }, - { type: "reasoning", id: "rs_second", summary: [], encrypted_content: secondEnvelope }, + { type: "reasoning", id: "rs_first", summary: [{ type: "summary_text", text: "first signed chain" }], encrypted_content: firstEnvelope }, + { type: "reasoning", id: "rs_second", summary: [{ type: "summary_text", text: "second signed chain" }], encrypted_content: secondEnvelope }, { type: "message", role: "assistant", content: [{ type: "output_text", text: "answer" }] }, { type: "message", role: "user", content: [{ type: "input_text", text: "next" }] }, ], diff --git a/tests/bridge-raw-reasoning-hidden.test.ts b/tests/bridge-raw-reasoning-hidden.test.ts index 14cc7ab2ce..37f0fdca41 100644 --- a/tests/bridge-raw-reasoning-hidden.test.ts +++ b/tests/bridge-raw-reasoning-hidden.test.ts @@ -1,12 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; -import { decodeReasoningEnvelope } from "../src/responses/reasoning-envelope"; import { clearReasoningReplayCacheForTests, peekReasoningForCall, } from "../src/responses/reasoning-replay-cache"; -import { parseRequest } from "../src/responses/parser"; -import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import type { AdapterEvent } from "../src/types"; async function* replay(events: AdapterEvent[]): AsyncGenerator { @@ -43,7 +40,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del clearReasoningReplayCacheForTests(); }); - test("streamed hidden: no reasoning_text deltas, envelope-only item, tool calls untouched", async () => { + test("streamed hidden: no reasoning output, tool calls untouched", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "chain " }, { type: "reasoning_raw_delta", text: "of thought" }, @@ -57,11 +54,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; const output = completed.output as Record[]; const reasoning = output.filter(o => o.type === "reasoning"); - expect(reasoning).toHaveLength(1); - expect(reasoning[0].content).toBeUndefined(); - expect(reasoning[0].summary).toEqual([]); - const envelope = decodeReasoningEnvelope(reasoning[0].encrypted_content as string); - expect(envelope?.txt).toBe("chain of thought"); + expect(reasoning).toHaveLength(0); const fc = output.find(o => o.type === "function_call") as Record; expect(fc).toMatchObject({ call_id: "call_1", name: "read_file" }); }); @@ -80,7 +73,7 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del }); }); - test("streamed hidden: thrown upstream still flushes the envelope before response.failed", async () => { + test("streamed hidden: thrown upstream does not expose reasoning before response.failed", async () => { async function* throwing(): AsyncGenerator { yield { type: "reasoning_raw_delta", text: "doomed thought" }; throw new Error("upstream exploded"); @@ -91,19 +84,16 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del const added = frames.filter(f => f.event === "response.output_item.added") .map(f => f.data.item as Record) .filter(i => i.type === "reasoning"); - expect(added).toHaveLength(1); - expect(decodeReasoningEnvelope(added[0].encrypted_content as string)?.txt).toBe("doomed thought"); + expect(added).toHaveLength(0); }); - test("non-streaming hidden: envelope-only item instead of raw content", () => { + test("non-streaming hidden: no raw reasoning item is emitted", () => { const json = buildResponseJSON([ { type: "reasoning_raw_delta", text: "quiet" }, { type: "done" }, ], "routed/model", { hideThinkingSummary: true }); const output = (json as { output: Record[] }).output; - const reasoning = output.find(o => o.type === "reasoning") as Record; - expect(reasoning.content).toBeUndefined(); - expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)?.txt).toBe("quiet"); + expect(output.find(o => o.type === "reasoning")).toBeUndefined(); }); test("non-streaming visible: raw shape unchanged", () => { @@ -117,31 +107,6 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del }); }); - test("replay: envelope-only item round-trips into reasoning_content for preserve-listed models", () => { - const json = buildResponseJSON([ - { type: "reasoning_raw_delta", text: "replay me" }, - { type: "done" }, - ], "routed/model", { hideThinkingSummary: true }); - const reasoningItem = (json as { output: Record[] }).output.find(o => o.type === "reasoning"); - const parsed = parseRequest({ - model: "glm-5.2", - stream: false, - input: [ - { type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }, - reasoningItem, - { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, - { type: "message", role: "user", content: [{ type: "input_text", text: "next" }] }, - ], - }); - const adapter = createOpenAIChatAdapter({ - adapter: "openai-chat", baseUrl: "https://api.z.ai/api/coding/paas/v4", apiKey: "k", - preserveReasoningContentModels: ["glm-5.2"], - }); - const body = JSON.parse(adapter.buildRequest(parsed).body) as { messages: Record[] }; - const assistant = body.messages.find(m => m.role === "assistant" && m.reasoning_content !== undefined); - expect(assistant?.reasoning_content).toBe("replay me"); - }); - test("streamed hidden: raw reasoning is recorded in the replay cache for the following tool call", async () => { await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "chain " },