diff --git a/.github/pr-assets/raw-reasoning-band.mov b/.github/pr-assets/raw-reasoning-band.mov new file mode 100644 index 0000000000..a9d5c73569 Binary files /dev/null and b/.github/pr-assets/raw-reasoning-band.mov differ diff --git a/devlog/_fin/260911_raw_reasoning_content_channel/010_record.md b/devlog/_fin/260911_raw_reasoning_content_channel/010_record.md new file mode 100644 index 0000000000..a2bb90f068 --- /dev/null +++ b/devlog/_fin/260911_raw_reasoning_content_channel/010_record.md @@ -0,0 +1,32 @@ +# 260911 — Raw reasoning rides the content channel + +## Problem + +Codex desktop renders the one-line animated thinking band from the Responses summary channel. +Commit 318315450 (issue #45) routed every raw `reasoning_raw_delta` into that summary channel so +non-OpenAI providers got an "expandable" trace, but the summary text there is the model's raw +chain of thought — GLM/DeepSeek/Grok chat streams scrolled unsummarized CoT through the band, +which only looks right for native OpenAI providers that author real summaries. + +## Change + +- `src/bridge.ts`: visible raw reasoning now streams on the CONTENT channel + (`response.reasoning_text.delta`, `content_index: 0`) and the final reasoning item carries + `content: [{type: "reasoning_text", text}]` with an empty `summary` — the native gpt-oss shape + documented in `100_codex-native-parity/51_raw-reasoning-bridge`. Codex applies its own display + policy: the desktop band shows the "Thinking…" placeholder, and the CLI still gates raw display + behind `show_raw_agent_reasoning`. +- Deleted the content-to-summary payload rewrite + (`src/server/responses-reasoning-summary-rewrite.ts`). Its only purpose was that display; + native Responses passthrough (DeepSeek) now round-trips content-channel reasoning unchanged, + which the upstream already accepts. +- Hidden mode (`hideThinkingSummary`, summary absent/"none") is unchanged: envelope-only item with + txt-only `ocxr1:` round-trip for `preserveReasoningContentModels` replay. +- Claude/kiro SIGNED `thinking_delta` visible mode is intentionally unchanged (summary channel); + the same content-channel treatment is a possible follow-up. + +## Verification + +- `bun run typecheck`; focused bridge, raw-reasoning, replay, xAI, web-search, and layout tests. +- The two web-search tests that fail only in multi-file batch runs fail identically on the + pre-change tree (pre-existing cross-file contamination, not caused here). diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 9b79f96b6a..c3ce8ba7c1 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1091,7 +1091,6 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", - "responses-reasoning-summary-rewrite.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", diff --git a/src/bridge.ts b/src/bridge.ts index 20e7c3fe09..bc90f35b94 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -663,16 +663,13 @@ export function bridgeToResponsesSSE( const closeCurrentRawReasoning = () => { if (!currentRawReasoning) return; rawReasoningForNextToolCall = currentRawReasoning.text; - emit("response.reasoning_summary_text.done", { - item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, text: currentRawReasoning.text, - }); - emit("response.reasoning_summary_part.done", { - item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, summary_index: 0, - part: { type: "summary_text", text: currentRawReasoning.text }, + emit("response.reasoning_text.done", { + item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, content_index: 0, text: currentRawReasoning.text, }); const item = { type: "reasoning", id: currentRawReasoning.itemId, - summary: [{ type: "summary_text", text: currentRawReasoning.text }], + summary: [] as never[], + content: [{ type: "reasoning_text", text: currentRawReasoning.text }], }; emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item }); retainFinishedItem(item as OutputItem, currentRawReasoning.textBytes, "reasoning"); @@ -1111,10 +1108,6 @@ export function bridgeToResponsesSSE( const itemId = `rs_${uuid()}`; const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] }; emit("response.output_item.added", { output_index: outputIndex, item }); - emit("response.reasoning_summary_part.added", { - item_id: itemId, output_index: outputIndex, summary_index: 0, - part: { type: "summary_text", text: "" }, - }); currentRawReasoning = { itemId, outputIndex, text: "", textBytes: 0 }; } ({ value: currentRawReasoning.text, bytes: currentRawReasoning.textBytes } = appendString( @@ -1123,9 +1116,13 @@ export function bridgeToResponsesSSE( event.text, "reasoning", )); - emit("response.reasoning_summary_text.delta", { + // Raw reasoning (openai-chat reasoning_content, kiro tags) rides the CONTENT + // channel, matching native gpt-oss passthrough: Codex applies its own display + // policy, so the desktop band shows the "Thinking…" placeholder instead of the + // raw CoT (the #45 summary-channel display intent is intentionally reverted). + emit("response.reasoning_text.delta", { item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex, - summary_index: 0, delta: event.text, + content_index: 0, delta: event.text, }); break; } @@ -1780,7 +1777,8 @@ function buildResponseJSONWithBudget( } pushOutput({ type: "reasoning", id: `rs_${uuid()}`, - summary: [{ type: "summary_text", text: currentRawReasoning }], + summary: [], + content: [{ type: "reasoning_text", text: currentRawReasoning }], }, currentRawReasoningBytes, "reasoning"); currentRawReasoning = ""; currentRawReasoningBytes = 0; diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts deleted file mode 100644 index 55c6d8ae7b..0000000000 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { SsePayloadRewrite } from "./sse-payload-rewrite"; - -/** - * Route content-channel reasoning from native-Responses upstreams through the - * expandable summary channel (issue #45). - * - * Codex renders the expandable reasoning trace from the Responses reasoning - * item's `summary[]` channel. DeepSeek's native `/responses` endpoint emits - * raw thinking on the content channel instead (`response.reasoning_text.delta` - * plus items with `content: [{type: "reasoning_text", text}]` and an empty - * `summary`), so routed DeepSeek turns showed the "Worked for Xs" timer with - * nothing to expand. Native OpenAI upstreams already emit summary-channel - * events; this rewrite is a no-op for them (no reasoning_text events to - * rewrite) and only engages when the upstream produces content-channel - * reasoning. - * - * Replay compatibility: Codex echoes the reasoning item it received back into - * the next request's input. DeepSeek's Responses API accepts summary-shaped - * reasoning input items (verified live), so the rewrite round-trips. - */ - -function isPlainObject(value: unknown): value is Record { - return !!value && typeof value === "object" && !Array.isArray(value); -} - -function reasoningTextOf(item: Record): string { - if (!Array.isArray(item.content)) return ""; - return item.content - .filter((part): part is Record => isPlainObject(part) && part.type === "reasoning_text") - .map(part => (typeof part.text === "string" ? part.text : "")) - .join(""); -} - -/** Move a reasoning item's content channel into the summary channel. */ -function reasoningItemToSummaryShape(item: Record): Record { - if (item.type !== "reasoning") return item; - // `encrypted_content` is opaque, state-bearing provider data, so the entire item must retain its - // upstream shape unless that backend has an explicit replay contract permitting a rewrite. This - // defensively protects content-channel backends that do issue blobs when the client replays the - // stored item. The delta rewrite can still provide the expandable trace for the live turn. - // DeepSeek — the provider this rewrite was verified against — is `statelessResponses` and issues - // no blob, so it is unaffected. - if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; - const text = reasoningTextOf(item); - // Items that already use the summary channel (or carry no content text at - // all) are left untouched: rewriting them could clear a valid summary. - if (text.length === 0) return item; - const next: Record = { ...item }; - delete next.content; - next.summary = [{ type: "summary_text", text }]; - return next; -} - -/** - * Rewrite one parsed SSE payload in place of the content channel, or return - * `null` when nothing changed (caller keeps the original payload). - */ -function rewritePayload(payload: Record): Record | null { - switch (payload.type) { - case "response.reasoning_text.delta": { - const next: Record = { - type: "response.reasoning_summary_text.delta", - item_id: payload.item_id, - output_index: payload.output_index, - summary_index: 0, - delta: payload.delta, - }; - if (payload.sequence_number !== undefined) next.sequence_number = payload.sequence_number; - return next; - } - case "response.reasoning_text.done": { - const next: Record = { - type: "response.reasoning_summary_text.done", - item_id: payload.item_id, - output_index: payload.output_index, - summary_index: 0, - text: payload.text, - }; - if (payload.sequence_number !== undefined) next.sequence_number = payload.sequence_number; - return next; - } - default: { - let changed = false; - const next: Record = { ...payload }; - if (isPlainObject(next.item) && next.item.type === "reasoning") { - const rewritten = reasoningItemToSummaryShape(next.item); - if (rewritten !== next.item) { - next.item = rewritten; - changed = true; - } - } - // SSE event shape: {type: "response.completed", response: {output}}. - const response = isPlainObject(next.response) ? { ...next.response } : null; - if (response && Array.isArray(response.output)) { - const output = response.output.map(item => { - if (!isPlainObject(item) || item.type !== "reasoning") return item; - const rewritten = reasoningItemToSummaryShape(item); - if (rewritten !== item) changed = true; - return rewritten; - }); - if (changed) { - response.output = output; - next.response = response; - } - } - // Bare response document shape (non-streaming passthrough): - // {object: "response", output: [...]}. - if (Array.isArray(next.output)) { - const output = next.output.map(item => { - if (!isPlainObject(item) || item.type !== "reasoning") return item; - const rewritten = reasoningItemToSummaryShape(item); - if (rewritten !== item) changed = true; - return rewritten; - }); - if (changed) next.output = output; - } - return changed ? next : null; - } - } -} - -/** Payload rewrite for passthrough relays whose upstream emits content-channel reasoning. */ -export function createReasoningSummaryChannelPayloadRewrite(): SsePayloadRewrite { - return (payload: string): string => { - let parsed: unknown; - try { - parsed = JSON.parse(payload); - } catch { - return payload; - } - if (!isPlainObject(parsed)) return payload; - const rewritten = rewritePayload(parsed); - return rewritten !== null ? JSON.stringify(rewritten) : payload; - }; -} - -/** - * Object-level variant for the non-streaming passthrough: the bounded-JSON - * relay bypasses the SSE payload rewrite, so reasoning items inside a full - * Responses JSON document need the same normalization before plain JSON - * serialization or forced JSON-to-SSE reframing. Returns the same reference - * when nothing changed. - */ -export function rewriteReasoningSummaryInJson(value: unknown): unknown { - if (!isPlainObject(value)) return value; - const rewritten = rewritePayload(value); - return rewritten !== null ? rewritten : value; -} - -/** String-level variant of {@link rewriteReasoningSummaryInJson}. */ -export function rewriteReasoningSummaryInJsonString(json: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return json; - } - const rewritten = rewriteReasoningSummaryInJson(parsed); - return rewritten === parsed ? json : JSON.stringify(rewritten); -} - -/** - * True when a routed native-Responses provider emits content-channel reasoning - * (raw `reasoning_text`) instead of the summary channel. DeepSeek's - * `/responses` endpoint is the current example: it ships raw thinking with an - * empty `summary` and keeps `preserveReasoningContentModels` so multi-turn - * replays round-trip. - */ -export function routeUsesContentChannelReasoning( - provider: { statelessResponses?: boolean; preserveReasoningContentModels?: string[] }, - modelId: string, -): boolean { - if (provider.statelessResponses === true) return true; - const preserved = provider.preserveReasoningContentModels; - const normalizedModelId = modelId.toLowerCase(); - return Array.isArray(preserved) - && preserved.some(id => id.toLowerCase() === normalizedModelId); -} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index cccd942026..8404e634a9 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -362,12 +362,6 @@ import { hasResponsesItemIdRepair, repairResponsesJsonItemIds, } from "../responses-item-id-repair"; -import { - createReasoningSummaryChannelPayloadRewrite, - rewriteReasoningSummaryInJson, - rewriteReasoningSummaryInJsonString, - routeUsesContentChannelReasoning, -} from "../responses-reasoning-summary-rewrite"; import { createImageGenCallRestoreRewrite, imageGenToolCallAliases, @@ -4978,10 +4972,7 @@ async function handleResponsesInner( ? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored))) : restored) as { id?: unknown; output?: unknown; status?: unknown }; // Replay overlap compares the items the client echoes, including visible reasoning shape. - const replayResponse = parsed.options.hideThinkingSummary !== true - && routeUsesContentChannelReasoning(route.provider, route.modelId) - ? rewriteReasoningSummaryInJson(restoredResponse) as typeof restoredResponse - : restoredResponse; + const replayResponse = restoredResponse; if ( undeclaredToolGuardActive && undeclaredToolCallNameInResponse( @@ -5975,10 +5966,6 @@ async function handleResponsesInner( ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, responseModelRewrite, - parsed.options.hideThinkingSummary !== true - && routeUsesContentChannelReasoning(route.provider, route.modelId) - ? createReasoningSummaryChannelPayloadRewrite() - : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); // #893: sparse-snapshot gateways get field backfills AND lifecycle event // injection at the block level, after payload rewrites. Defaults come @@ -6249,13 +6236,7 @@ async function handleResponsesInner( const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(repaired, parsed._responseModelId) : repaired; - // The bounded-JSON answer bypasses the SSE payload rewrite, so content- - // channel reasoning needs the same normalization here for the plain - // JSON answer and every reframed-SSE variant built from clientJson. - return parsed.options.hideThinkingSummary !== true - && routeUsesContentChannelReasoning(route.provider, route.modelId) - ? rewriteReasoningSummaryInJsonString(modelRewritten) - : modelRewritten; + return modelRewritten; })(); // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and // the reframed-SSE branch below are built from this body, so one check covers them. This diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e0b87add2d..7a52bf477f 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -215,18 +215,13 @@ honored by BOTH reasoning paths: anthropic `thinking_delta` AND raw `reasoning_r item (`summary: []`, txt-only `ocxr1:` `encrypted_content`, no text deltas) — invisible in the Codex app, so tool cells group like native models — while the text still round-trips for `preserveReasoningContentModels` replay. Visible mode (summary "auto") keeps the raw -`content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: -`devlog/_fin/260709_native_response_pattern/`. - -The content-to-summary channel rewrite skips any reasoning item that carries a native -`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must -round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. -This defensively protects providers that issue blobs and later join the route through -`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is -`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits -summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite -does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry -no blob and still route to the summary channel, so the live expandable trace is unchanged. +`content[reasoning_text]` shape: raw deltas stream as `response.reasoning_text.delta` and the final +item carries `content: [{type: "reasoning_text", text}]`, so Codex applies its own display policy — +the desktop thinking band shows the "Thinking…" placeholder, and raw text appears only when +`show_raw_agent_reasoning` is enabled. Routing raw CoT through the summary channel instead (the +#45 display intent, intentionally reverted 260911) put unsummarized thinking in the desktop band, +which only fits native OpenAI providers that author real summaries. Diagnosis and codex-rs +grouping evidence: `devlog/_fin/260709_native_response_pattern/`. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential diff --git a/tests/adapters/bridge-raw-reasoning-hidden.test.ts b/tests/adapters/bridge-raw-reasoning-hidden.test.ts index 4acc66ce62..162d98b206 100644 --- a/tests/adapters/bridge-raw-reasoning-hidden.test.ts +++ b/tests/adapters/bridge-raw-reasoning-hidden.test.ts @@ -77,18 +77,19 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del expect(fc).toMatchObject({ call_id: "call_1", name: "read_file" }); }); - test("streamed visible (flag off): raw reasoning rides the expandable summary channel (#2007)", async () => { + test("streamed visible (flag off): raw reasoning rides the content channel (#2007)", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "visible raw" }, { type: "done" }, ]), "routed/model")); - expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(true); - expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(false); + expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(true); + expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(false); const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; const output = completed.output as Record[]; expect(output[0]).toMatchObject({ type: "reasoning", - summary: [{ type: "summary_text", text: "visible raw" }], + summary: [], + content: [{ type: "reasoning_text", text: "visible raw" }], }); }); @@ -118,14 +119,15 @@ describe("hidden raw reasoning (hideThinkingSummary parity for reasoning_raw_del expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)?.txt).toBe("quiet"); }); - test("non-streaming visible: raw reasoning lands in the summary channel (#2007)", () => { + test("non-streaming visible: raw reasoning lands on the content channel (#2007)", () => { const json = buildResponseJSON([ { type: "reasoning_raw_delta", text: "loud" }, { type: "done" }, ], "routed/model", {}); const output = (json as { output: Record[] }).output; expect(output.find(o => o.type === "reasoning")).toMatchObject({ - summary: [{ type: "summary_text", text: "loud" }], + summary: [], + content: [{ type: "reasoning_text", text: "loud" }], }); }); diff --git a/tests/adapters/bridge.test.ts b/tests/adapters/bridge.test.ts index e9c2b050b6..f283b77015 100644 --- a/tests/adapters/bridge.test.ts +++ b/tests/adapters/bridge.test.ts @@ -87,27 +87,26 @@ describe("Responses bridge reasoning and usage parity", () => { expect(firstOutputs).toBe(1); }); - test("streaming raw reasoning is routed through the expandable summary channel", async () => { + test("streaming raw reasoning rides the content channel like native gpt-oss", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "reasoning_raw_delta", text: "raw detail" }, { type: "done", usage: { inputTokens: 10, outputTokens: 5, cachedInputTokens: 3, reasoningOutputTokens: 2 } }, ]), "routed/model")); - // Chat-completions providers (DeepSeek-style) deliver thinking as raw - // reasoning_content. Codex renders the expandable reasoning trace from the - // Responses summary channel only, so raw reasoning is routed through the - // summary channel (issue #45) instead of the content channel. - expect(frames.find(f => f.event === "response.reasoning_summary_text.delta")?.data) - .toMatchObject({ summary_index: 0, delta: "raw detail" }); - expect(frames.some(f => f.event === "response.reasoning_text.delta")).toBe(false); + // Raw reasoning_content rides the content channel so Codex applies its own display + // policy: the desktop band shows the "Thinking…" placeholder, and raw text appears + // only when show_raw_agent_reasoning is enabled — never as a fake summary. + expect(frames.find(f => f.event === "response.reasoning_text.delta")?.data) + .toMatchObject({ content_index: 0, delta: "raw detail" }); + expect(frames.some(f => f.event === "response.reasoning_summary_text.delta")).toBe(false); const completed = frames.find(f => f.event === "response.completed")?.data.response as Record; const output = completed.output as Record[]; expect(output[0]).toMatchObject({ type: "reasoning", - summary: [{ type: "summary_text", text: "raw detail" }], + summary: [], + content: [{ type: "reasoning_text", text: "raw detail" }], }); - expect((output[0] as { content?: unknown }).content).toBeUndefined(); expect(completed.usage).toMatchObject({ input_tokens: 10, input_tokens_details: { cached_tokens: 3 }, @@ -502,9 +501,9 @@ describe("Responses bridge reasoning and usage parity", () => { const output = json.output as Record[]; expect(output.map(item => item.type)).toEqual(["reasoning", "message"]); expect(output[0]).toMatchObject({ - summary: [{ type: "summary_text", text: "raw json" }], + summary: [], + content: [{ type: "reasoning_text", text: "raw json" }], }); - expect((output[0] as { content?: unknown }).content).toBeUndefined(); expect(json.usage).toMatchObject({ input_tokens: 6, input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b06cf54d29..10eab9abb5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -926,7 +926,6 @@ "responses-pool-401-refresh.test.ts": "responses", "responses-pool-refresh-attribution.test.ts": "responses", "responses-reasoning-summary-passthrough.test.ts": "responses", - "responses-reasoning-summary-rewrite.test.ts": "responses", "responses-routed-web-search-fields.test.ts": "responses", "responses-self-named-namespace-scrub.test.ts": "responses", "responses-shadow-intercept.test.ts": "responses", diff --git a/tests/providers/opencode-go-luna-wire.test.ts b/tests/providers/opencode-go-luna-wire.test.ts index c0afb15553..7866ec9415 100644 --- a/tests/providers/opencode-go-luna-wire.test.ts +++ b/tests/providers/opencode-go-luna-wire.test.ts @@ -163,19 +163,17 @@ describe("OpenCode Go stateless reasoning and continuation routes", () => { const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Run probe" }] }; const first = await drive({ input: [initial] }); expect(first.document.output[0]).toEqual(reasoning[0]); - expect(first.document.output[1]).toEqual(continuation.summary === "auto" ? { - type: "reasoning", id: `rs_${prefix}_content`, status: "completed", summary: [{ type: "summary_text", text: "Visible thinking" }], - } : reasoning[1]); + // The passthrough keeps native content-channel reasoning in both display modes. + expect(first.document.output[1]).toEqual(reasoning[1]); expect(first.document.output[2]).toEqual(reasoning[2]); expect(first.document.output[3]).toMatchObject(call); expect(first.document.output[4]).toEqual(priorMessage); if (streaming) { - const channel = continuation.summary === "auto" ? "reasoning_summary_text" : "reasoning_text"; - expect(first.text).toContain(`"type":"response.${channel}.delta"`); + expect(first.text).toContain('"type":"response.reasoning_text.delta"'); } const result = { type: "function_call_output", call_id: call.call_id, output: "probe succeeded" }; // Echo exactly the client-visible history through handleResponses. An upstream-shape - // cache would prepend it again after the content-to-summary rewrite (F1). + // cache would prepend it again (F1). const nextBody = { input: continuation.fullHistory ? [initial, ...first.document.output, result] : [result], previous_response_id: first.document.id, store: true, @@ -207,9 +205,8 @@ describe("OpenCode Go stateless reasoning and continuation routes", () => { expect(replay.filter(item => item.type === "reasoning")).toHaveLength(3); expect(replay).toContainEqual(expect.objectContaining({ type: "reasoning", encrypted_content: blob })); expect(JSON.stringify(replay)).toContain("Already summarized"); - if (continuation.summary === "auto") expect(replay).toContainEqual(expect.objectContaining({ - type: "reasoning", summary: [{ type: "summary_text", text: "Visible thinking" }], - })); + // Replay sanitation strips reasoning content in both display modes (F1), so the + // visible "Visible thinking" trace does not re-enter the upstream history. expect(JSON.stringify(replay)).not.toContain("no tool result was recorded"); }); } diff --git a/tests/responses/responses-reasoning-summary-passthrough.test.ts b/tests/responses/responses-reasoning-summary-passthrough.test.ts index 5912221354..ab6e099bcc 100644 --- a/tests/responses/responses-reasoning-summary-passthrough.test.ts +++ b/tests/responses/responses-reasoning-summary-passthrough.test.ts @@ -6,10 +6,12 @@ import type { OcxConfig } from "../../src/types"; /** * The passthrough relay for DeepSeek's native /responses endpoint emits - * content-channel reasoning (reasoning_text.delta + content items). The - * summary-channel rewrite must engage only when the client did NOT ask for - * hidden thinking (hideThinkingSummary) - otherwise a client that asked to - * hide reasoning would get it surfaced as visible summary output. + * content-channel reasoning (reasoning_text.delta + content items) in BOTH + * display modes: Codex applies its own raw-reasoning display policy, so a + * requested summary must not rewrite the native passthrough shape either. + * Hidden thinking (hideThinkingSummary) and visible summary get the same + * content-channel passthrough; the hidden variant additionally arrives as an + * envelope-only item upstream when the adapter layer handles suppression. */ function deepseekSeed() { @@ -86,15 +88,16 @@ describe("passthrough reasoning summary rewrite honors hideThinkingSummary", () expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); }); - test("SSE: requested summary routes raw reasoning through the summary channel", async () => { + test("SSE: requested summary keeps the native content-channel passthrough", async () => { const response = await runHandleResponses( { model: "deepseek-v4-flash", input: "ping", stream: true, reasoning: { effort: "max", summary: "detailed" } }, SSE_UPSTREAM_FRAMES.join(""), "text/event-stream", ); const text = await response.text(); - expect(text).toContain("response.reasoning_summary_text.delta"); - expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); + expect(text).toContain("response.reasoning_text.delta"); + expect(text).not.toContain("response.reasoning_summary_text.delta"); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); }); test("bounded JSON: hidden thinking keeps the content shape", async () => { @@ -108,14 +111,14 @@ describe("passthrough reasoning summary rewrite honors hideThinkingSummary", () expect(text).not.toContain('"summary":[{"type":"summary_text"'); }); - test("bounded JSON: requested summary moves item content into summary", async () => { + test("bounded JSON: requested summary keeps the content shape", async () => { const response = await runHandleResponses( { model: "deepseek-v4-flash", input: "ping", stream: false, reasoning: { effort: "max", summary: "detailed" } }, JSON_UPSTREAM, "application/json", ); const text = await response.text(); - expect(text).toContain('"summary":[{"type":"summary_text","text":"think"}]'); - expect(text).not.toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + expect(text).toContain('"content":[{"type":"reasoning_text","text":"think"}]'); + expect(text).not.toContain('"summary":[{"type":"summary_text"'); }); }); diff --git a/tests/responses/responses-reasoning-summary-rewrite.test.ts b/tests/responses/responses-reasoning-summary-rewrite.test.ts deleted file mode 100644 index 32940a24e7..0000000000 --- a/tests/responses/responses-reasoning-summary-rewrite.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - createReasoningSummaryChannelPayloadRewrite, - routeUsesContentChannelReasoning, - rewriteReasoningSummaryInJson, - rewriteReasoningSummaryInJsonString, -} from "../../src/server/responses-reasoning-summary-rewrite"; - -const rewrite = createReasoningSummaryChannelPayloadRewrite(); - -function apply(payload: unknown): unknown { - return JSON.parse(rewrite(JSON.stringify(payload))); -} - -describe("responses reasoning summary channel rewrite", () => { - test("routes reasoning_text.delta through the summary channel", () => { - expect(apply({ - type: "response.reasoning_text.delta", - content_index: 0, - delta: "think", - item_id: "rs_1", - output_index: 0, - sequence_number: 4, - })).toEqual({ - type: "response.reasoning_summary_text.delta", - summary_index: 0, - delta: "think", - item_id: "rs_1", - output_index: 0, - sequence_number: 4, - }); - }); - - test("routes reasoning_text.done through the summary channel", () => { - expect(apply({ - type: "response.reasoning_text.done", - content_index: 0, - text: "full thinking", - item_id: "rs_1", - output_index: 0, - })).toEqual({ - type: "response.reasoning_summary_text.done", - summary_index: 0, - text: "full thinking", - item_id: "rs_1", - output_index: 0, - }); - }); - - test("moves reasoning item content into summary on output_item.done", () => { - expect(apply({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - })).toEqual({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }, - }); - }); - - test("moves reasoning item content into summary inside response.completed", () => { - const payload = { - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK" }] }, - ], - }, - }; - const result = apply(payload) as { response: { output: Record[] } }; - expect(result.response.output[0]).toEqual({ - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }); - expect(result.response.output[1]).toEqual(payload.response.output[1]); - }); - - test("leaves summary-channel and message events untouched", () => { - const untouched = [ - { type: "response.reasoning_summary_text.delta", summary_index: 0, delta: "s", item_id: "rs_1", output_index: 0 }, - { type: "response.output_text.delta", content_index: 0, delta: "OK", item_id: "msg_1", output_index: 1 }, - { type: "response.output_item.added", output_index: 1, item: { type: "message", id: "msg_1", status: "in_progress", content: [] } }, - ]; - for (const payload of untouched) { - expect(apply(payload)).toEqual(payload); - } - }); - - test("leaves a reasoning item without content text untouched", () => { - expect(apply({ - type: "response.output_item.done", - output_index: 0, - item: { type: "reasoning", id: "rs_1", status: "completed", content: [], summary: [] }, - })).toEqual({ - type: "response.output_item.done", - output_index: 0, - item: { type: "reasoning", id: "rs_1", status: "completed", content: [], summary: [] }, - }); - }); - - test("preserves a summary-channel reasoning item as-is", () => { - expect(apply({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [], - summary: [{ type: "summary_text", text: "already summarized" }], - }, - })).toEqual({ - type: "response.output_item.done", - output_index: 0, - item: { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [], - summary: [{ type: "summary_text", text: "already summarized" }], - }, - }); - }); - - test("rewrites reasoning items inside a bare completed response document", () => { - const doc = { - id: "resp_1", - object: "response", - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - { type: "message", id: "msg_1", status: "completed", content: [{ type: "output_text", text: "OK" }] }, - ], - }; - const result = rewriteReasoningSummaryInJson(doc) as { output: Record[] }; - expect(result.output[0]).toEqual({ - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }); - expect(result.output[1]).toEqual(doc.output[1]); - }); - - test("rewrites reasoning items inside an SSE completed event document", () => { - const doc = { - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - output: [ - { - type: "reasoning", - id: "rs_1", - status: "completed", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }, - ], - }, - }; - const result = rewriteReasoningSummaryInJson(doc) as { response: { output: Record[] } }; - expect(result.response.output[0]).toEqual({ - type: "reasoning", - id: "rs_1", - status: "completed", - summary: [{ type: "summary_text", text: "thinking" }], - }); - }); - - test("string-level rewrite leaves summary-channel documents untouched", () => { - const doc = JSON.stringify({ - id: "resp_1", - output: [{ type: "reasoning", id: "rs_1", summary: [{ type: "summary_text", text: "already summarized" }] }], - }); - expect(rewriteReasoningSummaryInJsonString(doc)).toBe(doc); - }); - - test("malformed payloads pass through unchanged", () => { - expect(rewrite("not json")).toBe("not json"); - expect(rewrite("[1,2]")).toBe("[1,2]"); - }); - - // `encrypted_content` is opaque, state-bearing provider data, so preserve the complete item - // shape defensively when the client replays it. This rewrite's round-trip was verified against - // DeepSeek, which is stateless and issues no blob; providers that do issue one joined later - // through `preserveReasoningContentModels`. - describe("items carrying encrypted_content", () => { - const blobItem = { - type: "reasoning", - id: "rs_1", - status: "completed", - encrypted_content: "gAAAAAB-upstream-issued-blob", - content: [{ type: "reasoning_text", text: "thinking" }], - summary: [], - }; - - test("are returned byte-for-byte on output_item.done", () => { - const payload = { type: "response.output_item.done", output_index: 0, item: blobItem }; - expect(apply(payload)).toEqual(payload); - }); - - test("are returned byte-for-byte inside response.completed output", () => { - const payload = { - type: "response.completed", - response: { id: "resp_1", output: [blobItem] }, - }; - expect(apply(payload)).toEqual(payload); - }); - - test("are returned byte-for-byte through the non-streaming document rewrite", () => { - const doc = { id: "resp_1", object: "response", output: [blobItem] }; - expect(rewriteReasoningSummaryInJson(doc)).toBe(doc); - const json = JSON.stringify(doc); - expect(rewriteReasoningSummaryInJsonString(json)).toBe(json); - }); - - // Only the stored item is protected: the live trace Codex renders comes from the delta events, - // which carry no blob and are still routed to the summary channel. - test("do not disable the delta rewrite that renders the live trace", () => { - expect(apply({ - type: "response.reasoning_text.delta", - delta: "think", - item_id: "rs_1", - output_index: 0, - })).toMatchObject({ type: "response.reasoning_summary_text.delta", delta: "think" }); - }); - }); -}); - -describe("routeUsesContentChannelReasoning", () => { - test("statelessResponses providers use the content channel", () => { - expect(routeUsesContentChannelReasoning({ statelessResponses: true }, "deepseek-v4-flash")).toBe(true); - }); - - test("preserveReasoningContentModels lists qualify", () => { - expect(routeUsesContentChannelReasoning( - { preserveReasoningContentModels: ["deepseek-v4-flash"] }, - "deepseek-v4-flash", - )).toBe(true); - }); - - test("model matching is case-insensitive on both sides", () => { - expect(routeUsesContentChannelReasoning( - { preserveReasoningContentModels: ["DeepSeek-V4-Flash"] }, - "deepseek-v4-flash", - )).toBe(true); - expect(routeUsesContentChannelReasoning( - { preserveReasoningContentModels: ["deepseek-v4-flash"] }, - "DeepSeek-V4-Flash", - )).toBe(true); - }); - - test("other providers do not", () => { - expect(routeUsesContentChannelReasoning({}, "gpt-5.5")).toBe(false); - }); -}); diff --git a/tests/server/server-xai-chat-reasoning-streaming.test.ts b/tests/server/server-xai-chat-reasoning-streaming.test.ts index 5019147189..badc15fa9f 100644 --- a/tests/server/server-xai-chat-reasoning-streaming.test.ts +++ b/tests/server/server-xai-chat-reasoning-streaming.test.ts @@ -151,7 +151,7 @@ describe("xAI OAuth Chat reasoning streaming", () => { let received = ""; await Promise.race([ (async () => { - while (!received.includes("response.reasoning_summary_text.delta")) { + while (!received.includes("response.reasoning_text.delta")) { const chunk = await reader!.read(); if (chunk.done) throw new Error("stream ended before the first xAI reasoning delta"); received += decoder.decode(chunk.value, { stream: true }); @@ -184,7 +184,7 @@ describe("xAI OAuth Chat reasoning streaming", () => { if (chunk.done) break; received += decoder.decode(chunk.value, { stream: true }); } - const reasoningIndex = received.indexOf("response.reasoning_summary_text.delta"); + const reasoningIndex = received.indexOf("response.reasoning_text.delta"); const contentIndex = received.indexOf("response.output_text.delta"); const completedIndex = received.indexOf("response.completed"); expect(reasoningIndex).toBeGreaterThanOrEqual(0);