From 1f8e39b304ecd4276e8235cd08893c2ff2272bc1 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 18:32:43 +0800 Subject: [PATCH 1/3] feat(proxy): carry prior-turn reasoning through to the chat upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic clients echo thinking blocks back on later turns, and the `signature` on each block is what lets the server recover the reasoning behind it. Codex replays Responses reasoning items for the same reason. We dropped both, so a client following either contract lost its prior reasoning as soon as it sent a second request. We are the API as far as our clients are concerned, so mint and verify these ourselves rather than waiting on Anthropic: - Add `reasoning-seal`, sealing reasoning into an opaque, tamper-evident blob with aes-256-gcm. Reuses the storage encryption secret, and falls back to a plain encoding when it is unset so an unconfigured deployment still proxies successfully. Unsealing never throws — an unopenable value degrades to "no prior reasoning" instead of failing the request. - Emit the signature on outbound thinking blocks (streamed as a `signature_delta` just before the block closes) and on outbound Responses reasoning items, so clients have something replayable. - Recover inbound reasoning and send it upstream on the assistant message it belongs to, as `reasoning` — the field the CodeBuddy chat upstream round-trips, and the one its own client populates when it replays a response. - Handle `redacted_thinking`, which previously fell through to `stringifyContent` and reached the model as a JSON dump of the opaque payload. - Stop turning replayed Responses reasoning items into empty user turns. They had neither `role` nor `content`, so the fallback produced `{role:'user', content:''}` — a turn the user never sent, repeated and accumulated on every subsequent turn. The signature is preferred but never a gate: a client may replay a genuine Anthropic signature from a session started elsewhere, and that should fall back to the summary rather than discard good reasoning. --- lib/server/proxy/anthropic.ts | 103 +++++- lib/server/proxy/codebuddy.ts | 10 + lib/server/proxy/responses.ts | 132 +++++++- lib/server/shared/reasoning-seal.ts | 135 ++++++++ tests/server/reasoning-roundtrip.test.ts | 387 +++++++++++++++++++++++ tests/server/reasoning-seal.test.ts | 95 ++++++ 6 files changed, 855 insertions(+), 7 deletions(-) create mode 100644 lib/server/shared/reasoning-seal.ts create mode 100644 tests/server/reasoning-roundtrip.test.ts create mode 100644 tests/server/reasoning-seal.test.ts diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 38f1900..c32b416 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -20,6 +20,7 @@ import { toUpstreamTimeoutMessage, } from '../shared/upstream-timeout'; import { extractErrorMessage } from '../shared/http'; +import { sealReasoning, unsealReasoning } from '../shared/reasoning-seal'; import { markServerTool, normalizeToolName, @@ -50,6 +51,14 @@ interface AnthropicContentBlock { name?: string; input?: unknown; thinking?: string; + /** + * Opaque, tamper-evident copy of the reasoning, minted by `reasoning-seal`. + * Anthropic clients echo the block back verbatim; this is what lets us + * recover the reasoning on the next turn instead of only its summary. + */ + signature?: string; + /** Present on `redacted_thinking` blocks, which carry no readable text. */ + data?: string; tool_use_id?: string; content?: unknown; source?: AnthropicImageSource; @@ -359,6 +368,12 @@ interface ChatMessage { }; }>; tool_call_id?: string; + /** + * Prior-turn reasoning for this assistant message. Not part of the OpenAI + * schema; the CodeBuddy chat upstream round-trips it, and a provider that + * does not know the field ignores it. + */ + reasoning?: string; } const decodeOpaqueServerToolContent = (value: unknown): unknown => { @@ -499,11 +514,19 @@ const mapAnthropicContentToChat = ( }> = []; const toolResults: ChatMessage[] = []; const messages: ChatMessage[] = []; + /** + * Reasoning recovered from thinking blocks in this assistant message. + * + * Attached to the message the blocks belong to rather than sent on its own: + * a bare reasoning entry is not a valid chat message, and the upstream needs + * the reasoning alongside the text and tool calls it produced. + */ + let pendingReasoning = ''; const flushAssistantMessage = (): void => { const content = mapContentPartsToChat(parts); const hasContent = typeof content === 'string' ? content.length > 0 : true; - if (!toolCalls.length && !hasContent) { + if (!toolCalls.length && !hasContent && !pendingReasoning) { return; } @@ -511,9 +534,14 @@ const mapAnthropicContentToChat = ( role: 'assistant', content: hasContent ? content : null, ...(toolCalls.length ? { tool_calls: [...toolCalls] } : {}), + // `reasoning` is the field the CodeBuddy chat upstream round-trips. It + // is not part of the OpenAI schema, but the upstream accepts it and + // ignoring an unknown field costs nothing if it ever stops doing so. + ...(pendingReasoning ? { reasoning: pendingReasoning } : {}), }); parts.length = 0; toolCalls.length = 0; + pendingReasoning = ''; }; for (const block of content) { @@ -558,8 +586,35 @@ const mapAnthropicContentToChat = ( } else { toolResults.push(resultMessage); } - } else if (block.type === 'thinking') { - // Skip thinking blocks in conversation history for OpenAI compat. + } else if ( + block.type === 'thinking' || + block.type === 'redacted_thinking' + ) { + // Replaying prior-turn reasoning is required inside a tool-use turn and + // harmless elsewhere, so recover it instead of dropping it. + // + // The signature is preferred because it holds the reasoning verbatim, + // where `thinking` is only a summary (and empty under + // `display: "omitted"`). It is not a gate, though: a client may replay a + // genuine Anthropic signature from a session it started elsewhere, which + // we cannot open and should not treat as a reason to discard good + // reasoning. So fall back to the summary, then to the opaque payload. + // + // `redacted_thinking` carries no readable text at all, only `data`, so it + // must be matched here too: without this branch it fell through to + // `stringifyContent` and the model received a JSON dump of the opaque + // payload as if it were user prose. + const reasoning = + unsealReasoning(block.signature) ?? + block.thinking ?? + unsealReasoning(block.data) ?? + ''; + + if (reasoning) { + pendingReasoning = pendingReasoning + ? `${pendingReasoning}${reasoning}` + : reasoning; + } } else if (block.type === 'image' && block.source) { // Anthropic sends `{ type: 'image', source: { type: 'base64' | 'url', // media_type, data | url } }`. Emit a real image block so the upstream @@ -846,6 +901,25 @@ const buildAllAnthropicServerToolBlocks = ( ): AnthropicContentBlock[] => executions.flatMap(buildAnthropicServerToolBlocks); +/** + * Builds a thinking block. + * + * The `signature` is what makes the block replayable. Anthropic clients echo the + * whole block back on the next turn, and the signature is what lets the server + * recover the reasoning behind it. We mint it ourselves (see `reasoning-seal`) + * so a client following the Anthropic contract gets a working round trip against + * this proxy rather than only a readable summary. + * + * Omitted when there is nothing to seal, so an empty block never carries one. + */ +const buildThinkingBlock = (thinking: string): AnthropicContentBlock => { + const signature = sealReasoning(thinking); + + return signature + ? { type: 'thinking', thinking, signature } + : { type: 'thinking', thinking }; +}; + /** * Lays a server-tool turn out the way Anthropic does: each hop contributes its * own thinking and text, followed by the tool blocks that hop triggered. @@ -870,7 +944,7 @@ const buildAnthropicTurnBlocks = ( turns.forEach((turn) => { if (turn.reasoning) { - blocks.push({ type: 'thinking', thinking: turn.reasoning }); + blocks.push(buildThinkingBlock(turn.reasoning)); } if (turn.text) { @@ -909,7 +983,7 @@ const mapOpenAIResponseToAnthropic = ( if (!turns) { if (reasoningText) { - contentBlocks.push({ type: 'thinking', thinking: reasoningText }); + contentBlocks.push(buildThinkingBlock(reasoningText)); } if (textContent) { @@ -1026,6 +1100,7 @@ const mapOpenAIStreamToAnthropicSSE = ( let started = options?.emitMessageStart === false; let thinkingStarted = false; let thinkingBlockIndex = -1; + let thinkingText = ''; let textStarted = false; let textBlockIndex = -1; // Tracks how many content blocks (thinking + text) have been opened @@ -1050,11 +1125,28 @@ const mapOpenAIStreamToAnthropicSSE = ( // Anthropic streaming requires each block to be stopped before the next. const closeOpenTextBlocks = (): void => { if (thinkingStarted) { + // Anthropic emits the signature last, just before the block closes, so a + // client that reassembles blocks (SDK `finalMessage()`) ends up with a + // replayable thinking block rather than bare prose. + const signature = sealReasoning(thinkingText); + + if (signature) { + enqueueEvent({ + type: 'content_block_delta', + index: thinkingBlockIndex, + delta: { + type: 'signature_delta', + signature, + }, + }); + } + enqueueEvent({ type: 'content_block_stop', index: thinkingBlockIndex, }); thinkingStarted = false; + thinkingText = ''; } if (textStarted) { @@ -1126,6 +1218,7 @@ const mapOpenAIStreamToAnthropicSSE = ( thinking: reasoningText, }, }); + thinkingText += reasoningText; } // Text content diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts index 6992e2b..159f505 100644 --- a/lib/server/proxy/codebuddy.ts +++ b/lib/server/proxy/codebuddy.ts @@ -56,6 +56,16 @@ interface OpenAIMessage { content?: unknown; tool_calls?: unknown[]; tool_call_id?: string; + /** + * Prior-turn reasoning for an assistant message. + * + * Not an OpenAI field. The CodeBuddy chat upstream accepts it on assistant + * messages and uses it to carry reasoning across turns — the same slot + * CodeBuddy's own client populates when it replays a response. It is named + * `reasoning` rather than `reasoning_content` because that one is the + * upstream's *response* field; this is the request-side counterpart. + */ + reasoning?: string; } interface CacheableTextBlock { diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index 41e3f92..f774ce2 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -39,6 +39,7 @@ import { } from './web-search-loop'; import { resolveRequestAccessKey } from './auth'; import { createErrorResponse } from '../shared/http'; +import { sealReasoning, unsealReasoning } from '../shared/reasoning-seal'; import { createStreamCloser, readTimeoutFrame, @@ -63,6 +64,17 @@ interface ResponsesInputItem { name?: string; call_id?: string; tools?: Array<{ type?: string; name?: string } & Record>; + /** + * Present on `reasoning` items a client replays from an earlier response. + * Opaque to us — we mint and verify these ourselves, see `reasoning-seal`. + * A compaction item carries the same field. + */ + encrypted_content?: string; + /** + * Reasoning summaries. The Agents SDK sends these back as + * `summary: [{type: 'summary_text', text}]`. + */ + summary?: unknown; } interface SupportedChatTool { @@ -128,6 +140,9 @@ interface ChatResponseToolCall { interface ChatResponseMessage { content?: unknown; tool_calls?: ChatResponseToolCall[]; + /** Reasoning the upstream produced alongside `content`. */ + reasoning_content?: string; + reasoning?: string; } interface ChatImagePart { @@ -161,6 +176,14 @@ interface TranscriptMessage { }; }>; tool_call_id?: string; + /** + * Prior-turn reasoning recovered from a replayed reasoning item. + * + * Carried on the assistant message the reasoning belongs to rather than sent + * as its own message: the chat upstream has no standalone reasoning entry, + * and a reasoning-only message would be an empty turn. + */ + reasoning?: string; } interface StreamingToolCallState { @@ -875,7 +898,59 @@ const stringifyContent = (value: unknown): string => { return JSON.stringify(value); }; -const mapInputItemToMessage = (item: ResponsesInputItem): TranscriptMessage => { +/** + * Pulls readable reasoning out of a replayed `reasoning` item. + * + * The sealed blob is authoritative when we can open it. Otherwise the summary + * text is a usable stand-in: the Agents SDK sends summaries back as + * `summary: [{type: 'summary_text', text}]`, so accepting them means a client + * that never got a signature from us still gets its reasoning carried through + * rather than dropped. + */ +const extractReasoningFromItem = (item: ResponsesInputItem): string => { + const sealed = unsealReasoning(item.encrypted_content); + + if (sealed) { + return sealed; + } + + if (!Array.isArray(item.summary)) { + return ''; + } + + return item.summary + .map((entry) => { + if (typeof entry === 'string') { + return entry; + } + + if (entry && typeof entry === 'object' && 'text' in entry) { + return String((entry as { text?: unknown }).text ?? ''); + } + + return ''; + }) + .join(''); +}; + +const mapInputItemToMessage = ( + item: ResponsesInputItem, +): TranscriptMessage | null => { + if (item.type === 'reasoning' || item.type === 'compaction') { + // Reasoning is not a message. Without this branch the item fell through to + // the plain-message case at the bottom, where it has neither `role` nor + // `content` — becoming an empty `{role:'user', content:''}` entry that the + // chat upstream sees as a turn the user never sent, repeated on every + // later turn of the conversation. + // + // Signal the reasoning back to the caller instead, which attaches it to the + // assistant message it accompanies. Returning `null` when there is nothing + // to recover keeps an empty reasoning item from emitting a message at all. + const reasoning = extractReasoningFromItem(item); + + return reasoning ? { role: 'assistant', content: null, reasoning } : null; + } + if (item.type === 'function_call' || item.type === 'mcp_call') { return { role: 'assistant', @@ -949,6 +1024,10 @@ const createMessageId = (): string => { return `msg_${crypto.randomUUID().replaceAll('-', '')}`; }; +const createResponseReasoningId = (): string => { + return `rs_${crypto.randomUUID().replaceAll('-', '')}`; +}; + const createResponseOutputId = (): string => { return `fc_${crypto.randomUUID().replaceAll('-', '')}`; }; @@ -1183,6 +1262,9 @@ const prepareTranscript = async ( const transcript = (resolvedPreviousSession?.transcript ?? []).slice( -MAX_RESPONSE_TRANSCRIPT_MESSAGES, ); + // Reasoning recovered from replayed reasoning items, awaiting the assistant + // message it belongs to. Declared here so it spans the whole input array. + let pendingReasoning = ''; while (transcript[0]?.role === 'tool') { transcript.shift(); } @@ -1227,7 +1309,31 @@ const prepareTranscript = async ( } else if (Array.isArray(body.input)) { body.input.forEach((item) => { if (item.type === 'additional_tools') return; - transcript.push(mapInputItemToMessage(item)); + + const message = mapInputItemToMessage(item); + + if (!message) { + return; + } + + // A reasoning item yields a reasoning-only entry. Fold it into the next + // assistant message so the upstream sees the reasoning where it belongs + // — attached to the turn that produced it — instead of as a bare turn. + // Anything left unconsumed at the end is dropped: reasoning with no + // following assistant message has nothing to attach to. + if (message.reasoning && !message.content && !message.tool_calls) { + pendingReasoning += message.reasoning; + return; + } + + if (pendingReasoning) { + message.reasoning = message.reasoning + ? `${pendingReasoning}${message.reasoning}` + : pendingReasoning; + pendingReasoning = ''; + } + + transcript.push(message); }); } @@ -1375,6 +1481,28 @@ const mapChatResponseToResponsesPayload = async ( defaults.tools, ); + // Emit the reasoning as its own item, ahead of the message it produced. + // + // Clients replay `output` verbatim on the next turn, so this is what lets a + // stateless Responses client carry reasoning forward. Without it the only + // reasoning we ever hand back is a transient `reasoning_text.delta`, which no + // client can replay because it has no id and no sealed blob. + const reasoningText = + firstChoice.message?.reasoning_content ?? + firstChoice.message?.reasoning ?? + ''; + const sealedReasoning = sealReasoning(reasoningText); + + if (reasoningText) { + output.push({ + id: createResponseReasoningId(), + type: 'reasoning', + summary: [{ type: 'summary_text', text: reasoningText }], + ...(sealedReasoning ? { encrypted_content: sealedReasoning } : {}), + status: 'completed', + }); + } + if (outputText || !toolCalls.length) { output.push({ id: createMessageId(), diff --git a/lib/server/shared/reasoning-seal.ts b/lib/server/shared/reasoning-seal.ts new file mode 100644 index 0000000..1497c92 --- /dev/null +++ b/lib/server/shared/reasoning-seal.ts @@ -0,0 +1,135 @@ +import crypto from 'node:crypto'; + +/** + * Reasoning seals. + * + * Anthropic's `thinking` blocks carry a `signature`: an opaque, encrypted copy + * of the full reasoning that a client echoes back unchanged on later turns so + * the model can continue from where it left off. Codex's Responses reasoning + * items carry `encrypted_content` for the same purpose. + * + * We are the API as far as our clients are concerned, so we mint and verify + * these ourselves — nothing here talks to Anthropic. Two properties matter: + * + * - **Opaque and tamper-evident.** The client must not be able to read or + * forge the reasoning, exactly as with the real thing. aes-256-gcm gives + * both; the auth tag makes a modified blob fail to verify rather than + * decrypt to garbage. + * - **Lossless.** Unsealing has to return the reasoning we sealed, because + * that string is what we hand back to the chat upstream. + * + * The key is the same environment secret the storage layer already uses, so + * operators configure one thing. When it is absent we fall back to a plain + * encoding instead of throwing: reasoning round-tripping is an enhancement, + * and a deployment without the secret set should still proxy successfully. + */ + +const SEAL_ENV = 'CODEBUDDY_STORAGE_ENCRYPTION_KEY'; + +/** Prefix marking the unencrypted fallback, so unseal never guesses. */ +const PLAIN_PREFIX = 'cbr1:'; + +/** Prefix marking a sealed blob, carrying the iv and auth tag alongside it. */ +const SEALED_PREFIX = 'cbs1:'; + +const IV_BYTES = 12; +const TAG_BYTES = 16; + +const createSealKey = (): Buffer | null => { + const source = process.env[SEAL_ENV]?.trim(); + + if (!source) { + return null; + } + + return crypto.createHash('sha256').update(source).digest(); +}; + +/** + * Encodes prior-turn reasoning as an opaque value safe to hand to a client. + * + * Returns `undefined` for empty input so callers can omit the field entirely + * rather than emit a signature for nothing — an empty thinking block is + * meaningful to some clients and noise to others. + */ +export const sealReasoning = (reasoning: string): string | undefined => { + if (!reasoning) { + return undefined; + } + + const key = createSealKey(); + + if (!key) { + return `${PLAIN_PREFIX}${Buffer.from(reasoning, 'utf8').toString( + 'base64url', + )}`; + } + + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.from(reasoning, 'utf8')), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return `${SEALED_PREFIX}${Buffer.concat([iv, tag, ciphertext]).toString( + 'base64url', + )}`; +}; + +/** + * Recovers reasoning from a value produced by {@link sealReasoning}. + * + * Unrecognised or tampered input yields `undefined` rather than throwing: a + * client may replay a genuine Anthropic signature (which we cannot decrypt) or + * a block from another deployment, and either should degrade to "no prior + * reasoning" instead of failing the request. + */ +export const unsealReasoning = (sealed: unknown): string | undefined => { + if (typeof sealed !== 'string' || !sealed) { + return undefined; + } + + if (sealed.startsWith(PLAIN_PREFIX)) { + try { + const decoded = Buffer.from( + sealed.slice(PLAIN_PREFIX.length), + 'base64url', + ).toString('utf8'); + + return decoded || undefined; + } catch { + return undefined; + } + } + + if (!sealed.startsWith(SEALED_PREFIX)) { + return undefined; + } + + const key = createSealKey(); + + if (!key) { + // Sealed under a key we no longer have (secret rotated away or unset). + return undefined; + } + + try { + const buffer = Buffer.from(sealed.slice(SEALED_PREFIX.length), 'base64url'); + const iv = buffer.subarray(0, IV_BYTES); + const tag = buffer.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const encrypted = buffer.subarray(IV_BYTES + TAG_BYTES); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString('utf8'); + + return plaintext || undefined; + } catch { + // Auth-tag mismatch (tampered) or malformed base64. + return undefined; + } +}; diff --git a/tests/server/reasoning-roundtrip.test.ts b/tests/server/reasoning-roundtrip.test.ts new file mode 100644 index 0000000..0012db1 --- /dev/null +++ b/tests/server/reasoning-roundtrip.test.ts @@ -0,0 +1,387 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NextRequest } from 'next/server'; + +import { addCredential } from '@/lib/server/domain/credentials'; +import { handleMessagesRequest } from '@/lib/server/proxy/anthropic'; +import { handleResponsesRequest } from '@/lib/server/proxy/responses'; +import { sealReasoning } from '@/lib/server/shared/reasoning-seal'; + +const repoRoot = process.cwd(); +const tempRootDir = path.join(repoRoot, '.tmp-test-reasoning-roundtrip'); + +const cleanupTempState = (): void => { + fs.rmSync(tempRootDir, { force: true, recursive: true }); +}; + +const makeAnthropicRequest = (): NextRequest => + new NextRequest('http://localhost/v1/messages', { method: 'POST' }); + +const makeResponsesRequest = (): NextRequest => + new NextRequest('http://localhost/v1/responses', { method: 'POST' }); + +const chatResponse = (content: string, reasoning?: string): Response => + new Response( + JSON.stringify({ + choices: [ + { + message: { + content, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + +/** Captures the body we send upstream, so tests assert on the real payload. */ +const captureUpstreamBody = async ( + send: () => Promise, + upstream: Response = chatResponse('the answer'), +): Promise | undefined> => { + let captured: Record | undefined; + const original = globalThis.fetch; + + globalThis.fetch = (async (...args: Parameters) => { + const [, init] = args; + + if (init?.body && typeof init.body === 'string') { + try { + captured = JSON.parse(init.body) as Record; + } catch { + // Not JSON — ignore. + } + } + + return upstream; + }) as typeof fetch; + + try { + await send(); + } finally { + globalThis.fetch = original; + } + + return captured; +}; + +interface UpstreamMessage { + role?: string; + content?: unknown; + reasoning?: string; +} + +const upstreamMessages = ( + body: Record | undefined, +): UpstreamMessage[] => (body?.messages ?? []) as UpstreamMessage[]; + +describe('reasoning round trip', () => { + const originalKey = process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; + + beforeEach(() => { + cleanupTempState(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.spyOn(process, 'cwd').mockReturnValue(tempRootDir); + process.env.CODEBUDDY_CONFIG_PATH = '.codebuddy_data/runtime.json'; + process.env.CODEBUDDY_AUTH_MODE = 'auto'; + process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = 'roundtrip-test-secret'; + addCredential({ + bearer_token: 'roundtrip-token', + responses_passthrough: false, + user_id: 'roundtrip@example.com', + }); + }); + + afterEach(() => { + cleanupTempState(); + + if (originalKey === undefined) { + delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; + } else { + process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = originalKey; + } + }); + + describe('claude code (/v1/messages)', () => { + it('emits a signature so the thinking block is replayable', async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + chatResponse('the answer', 'the model reasoned about primes')) as never; + + let content: Array> = []; + try { + const response = await handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [{ content: 'hi', role: 'user' }], + model: 'claude-sonnet-4-5', + } as never); + const json = (await response.json()) as { + content?: Array>; + }; + content = json.content ?? []; + } finally { + globalThis.fetch = original; + } + + const thinking = content.find((block) => block.type === 'thinking'); + + expect(thinking?.thinking).toBe('the model reasoned about primes'); + expect(typeof thinking?.signature).toBe('string'); + expect(thinking?.signature).toBeTruthy(); + }); + + it('recovers replayed reasoning and sends it upstream', async () => { + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { thinking: 'Claude Code replays this', type: 'thinking' }, + { text: 'the answer', type: 'text' }, + ], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.content).toBe('the answer'); + expect(assistant?.reasoning).toBe('Claude Code replays this'); + }); + + it('prefers the sealed signature but falls back to the summary', async () => { + // A client may replay a genuine Anthropic signature from a session it + // started against real Claude. We cannot open those, and discarding the + // summary over that would lose perfectly good reasoning — so the + // signature is preferred when valid, never a gate on the fallback. + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { + signature: 'WaUjzkypQ2mUEVM36O2Txu....', + thinking: 'summary text', + type: 'thinking', + }, + { text: 'the answer', type: 'text' }, + ], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('summary text'); + }); + + it('prefers the signature over the summary when it opens', async () => { + const sealed = sealReasoning('verbatim reasoning from the signature'); + + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { + signature: sealed, + thinking: 'a shorter summary', + type: 'thinking', + }, + { text: 'the answer', type: 'text' }, + ], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe( + 'verbatim reasoning from the signature', + ); + }); + + it('carries an omitted-display block whose summary is empty', async () => { + // Under `display: "omitted"` the thinking field is empty and the + // signature is the only payload. Replaying it must still work. + const sealed = sealReasoning('reasoning hidden from display'); + + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { signature: sealed, thinking: '', type: 'thinking' }, + { text: 'the answer', type: 'text' }, + ], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('reasoning hidden from display'); + }); + + it('no longer leaks redacted_thinking into the message body', async () => { + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { data: 'OPAQUE_ENCRYPTED_PAYLOAD', type: 'redacted_thinking' }, + { text: 'the answer', type: 'text' }, + ], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.content).toBe('the answer'); + expect(JSON.stringify(assistant?.content)).not.toContain( + 'redacted_thinking', + ); + }); + + it('stays silent when there is no reasoning to replay', async () => { + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [{ text: 'the answer', type: 'text' }], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBeUndefined(); + }); + }); + + describe('codex (/v1/responses)', () => { + it('emits a replayable reasoning item', async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => + chatResponse('the answer', 'codex upstream reasoning')) as never; + + let output: Array> = []; + try { + const response = await handleResponsesRequest(makeResponsesRequest(), { + input: 'hi', + model: 'gpt-5.5', + } as never); + const json = (await response.json()) as { + output?: Array>; + }; + output = json.output ?? []; + } finally { + globalThis.fetch = original; + } + + const reasoning = output.find((item) => item.type === 'reasoning'); + + expect(reasoning).toBeDefined(); + expect(reasoning?.id).toBeTruthy(); + expect(typeof reasoning?.encrypted_content).toBe('string'); + }); + + it('attaches replayed reasoning to the assistant turn instead of an empty user turn', async () => { + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'what is the weather?' }, + { + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'check the weather' }], + type: 'reasoning', + }, + { role: 'assistant', content: 'checking' }, + { role: 'user', content: 'and tomorrow?' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const messages = upstreamMessages(body); + + // The bug this fixes: the reasoning item used to become an extra + // `{"role":"user","content":""}` turn, which accumulated every round. + expect(messages.filter((m) => m.content === '')).toHaveLength(0); + + const assistant = messages.find((m) => m.role === 'assistant'); + + expect(assistant?.reasoning).toBe('check the weather'); + }); + + it('drops a reasoning item that carries nothing recoverable', async () => { + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'hi' }, + { id: 'rs_2', type: 'reasoning' }, + { role: 'assistant', content: 'the answer' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const messages = upstreamMessages(body); + + expect(messages).toHaveLength(2); + expect(messages.filter((m) => m.content === '')).toHaveLength(0); + }); + }); +}); diff --git a/tests/server/reasoning-seal.test.ts b/tests/server/reasoning-seal.test.ts new file mode 100644 index 0000000..a3aa65e --- /dev/null +++ b/tests/server/reasoning-seal.test.ts @@ -0,0 +1,95 @@ +import { + sealReasoning, + unsealReasoning, +} from '@/lib/server/shared/reasoning-seal'; + +const KEY = 'test-seal-secret'; + +describe('reasoning seals', () => { + const originalKey = process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; + + afterEach(() => { + if (originalKey === undefined) { + delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; + } else { + process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = originalKey; + } + }); + + describe('with a key configured', () => { + beforeEach(() => { + process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = KEY; + }); + + it('round-trips reasoning', () => { + const sealed = sealReasoning('the model thought about primes'); + + expect(sealed).toBeDefined(); + expect(unsealReasoning(sealed)).toBe('the model thought about primes'); + }); + + it('round-trips unicode and newlines without mangling', () => { + const reasoning = '第一步:检查缓存。\nline two — em dash ✓'; + + expect(unsealReasoning(sealReasoning(reasoning))).toBe(reasoning); + }); + + it('produces an opaque blob that does not contain the plaintext', () => { + const sealed = sealReasoning('super secret reasoning'); + + expect(sealed).toBeDefined(); + expect(sealed).not.toContain('super secret reasoning'); + }); + + it('randomises the iv so sealing twice differs', () => { + expect(sealReasoning('same input')).not.toBe(sealReasoning('same input')); + }); + + it('rejects a tampered blob instead of decrypting to garbage', () => { + const sealed = sealReasoning('original reasoning') ?? ''; + const tampered = `${sealed.slice(0, -2)}${sealed.slice(-2) === 'AA' ? 'BB' : 'AA'}`; + + expect(unsealReasoning(tampered)).toBeUndefined(); + }); + }); + + describe('without a key configured', () => { + beforeEach(() => { + delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; + }); + + it('still round-trips via the plain fallback', () => { + expect(unsealReasoning(sealReasoning('no key here'))).toBe('no key here'); + }); + + it('returns undefined for a blob sealed under a key we no longer have', () => { + process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = KEY; + const sealed = sealReasoning('sealed with a key') ?? ''; + delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; + + expect(unsealReasoning(sealed)).toBeUndefined(); + }); + }); + + describe('degenerate input', () => { + beforeEach(() => { + process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = KEY; + }); + + it('returns undefined for empty reasoning so no signature is emitted', () => { + expect(sealReasoning('')).toBeUndefined(); + }); + + it('returns undefined for a foreign signature we cannot open', () => { + // What a client replays when the block came from a real Anthropic call. + expect(unsealReasoning('WaUjzkypQ2mUEVM36O2Txu....')).toBeUndefined(); + }); + + it('returns undefined for non-string and empty input', () => { + expect(unsealReasoning(undefined)).toBeUndefined(); + expect(unsealReasoning(null)).toBeUndefined(); + expect(unsealReasoning(42)).toBeUndefined(); + expect(unsealReasoning('')).toBeUndefined(); + }); + }); +}); From 6c23fbcb4b37826cf4649926f50934d146952b56 Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 19:55:54 +0800 Subject: [PATCH 2/3] refactor(proxy): carry reasoning in the clear instead of sealing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reasoning we hand to clients is the upstream's own summary, and it already travels in the clear in the `thinking` field beside the signature. Encrypting a second copy protected nothing — the plaintext was right there — while adding a module, a key dependency, and a failure mode (a deployment without the secret silently changed behaviour). Drop `reasoning-seal` and pass the reasoning through as-is: - Anthropic path: thinking blocks carry their text in `thinking`, which is what clients echo back and what inbound handling reads. No signature is minted, because a replayable one would have to duplicate the text — and that duplication is not hypothetical: emitting it made the multi-hop server-tool tests see the reasoning twice. - Responses path: `encrypted_content` carries the reasoning verbatim. Codex treats the field as an opaque string it never opens, so plaintext round-trips exactly as well. - Values we minted are marked with a `cbreason1:` prefix. Not a security measure — it is what stops us reading a genuinely encrypted blob (an Anthropic signature, an OpenAI-issued blob) as reasoning text and forwarding ciphertext upstream. Unmarked values fall back to the summary. --- lib/server/proxy/anthropic.ts | 76 +++----- lib/server/proxy/responses.ts | 103 +++++++++-- lib/server/shared/reasoning-seal.ts | 135 --------------- tests/server/reasoning-roundtrip.test.ts | 210 ++++++++++++++++++++--- tests/server/reasoning-seal.test.ts | 95 ---------- 5 files changed, 301 insertions(+), 318 deletions(-) delete mode 100644 lib/server/shared/reasoning-seal.ts delete mode 100644 tests/server/reasoning-seal.test.ts diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index c32b416..3db86a6 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -20,7 +20,6 @@ import { toUpstreamTimeoutMessage, } from '../shared/upstream-timeout'; import { extractErrorMessage } from '../shared/http'; -import { sealReasoning, unsealReasoning } from '../shared/reasoning-seal'; import { markServerTool, normalizeToolName, @@ -52,9 +51,10 @@ interface AnthropicContentBlock { input?: unknown; thinking?: string; /** - * Opaque, tamper-evident copy of the reasoning, minted by `reasoning-seal`. - * Anthropic clients echo the block back verbatim; this is what lets us - * recover the reasoning on the next turn instead of only its summary. + * Accepted on inbound blocks but never sent by us — see + * `buildThinkingBlock`. Anthropic's signatures hold an encrypted copy of the + * reasoning; a client may replay one from a session it started elsewhere, and + * we skip those rather than forward ciphertext as if it were text. */ signature?: string; /** Present on `redacted_thinking` blocks, which carry no readable text. */ @@ -593,22 +593,16 @@ const mapAnthropicContentToChat = ( // Replaying prior-turn reasoning is required inside a tool-use turn and // harmless elsewhere, so recover it instead of dropping it. // - // The signature is preferred because it holds the reasoning verbatim, - // where `thinking` is only a summary (and empty under - // `display: "omitted"`). It is not a gate, though: a client may replay a - // genuine Anthropic signature from a session it started elsewhere, which - // we cannot open and should not treat as a reason to discard good - // reasoning. So fall back to the summary, then to the opaque payload. + // The `thinking` field carries the reasoning. A `signature` is only ever + // read when it is one we minted on the Responses path; a genuine + // Anthropic signature is ciphertext, and forwarding it upstream would put + // gibberish where reasoning belongs. // - // `redacted_thinking` carries no readable text at all, only `data`, so it - // must be matched here too: without this branch it fell through to + // `redacted_thinking` has no readable text at all, only `data`, but must + // still be matched here: without this branch it fell through to // `stringifyContent` and the model received a JSON dump of the opaque // payload as if it were user prose. - const reasoning = - unsealReasoning(block.signature) ?? - block.thinking ?? - unsealReasoning(block.data) ?? - ''; + const reasoning = block.thinking ?? ''; if (reasoning) { pendingReasoning = pendingReasoning @@ -902,23 +896,15 @@ const buildAllAnthropicServerToolBlocks = ( executions.flatMap(buildAnthropicServerToolBlocks); /** - * Builds a thinking block. - * - * The `signature` is what makes the block replayable. Anthropic clients echo the - * whole block back on the next turn, and the signature is what lets the server - * recover the reasoning behind it. We mint it ourselves (see `reasoning-seal`) - * so a client following the Anthropic contract gets a working round trip against - * this proxy rather than only a readable summary. - * - * Omitted when there is nothing to seal, so an empty block never carries one. + * We do not mint a `signature` on this path. It would have to duplicate the + * `thinking` text to be replayable, which puts the reasoning on the wire twice + * for callers that count it — and the block already replays fine: Anthropic + * clients echo `thinking` back, which is what inbound handling reads. */ -const buildThinkingBlock = (thinking: string): AnthropicContentBlock => { - const signature = sealReasoning(thinking); - - return signature - ? { type: 'thinking', thinking, signature } - : { type: 'thinking', thinking }; -}; +const buildThinkingBlock = (thinking: string): AnthropicContentBlock => ({ + type: 'thinking', + thinking, +}); /** * Lays a server-tool turn out the way Anthropic does: each hop contributes its @@ -1100,7 +1086,6 @@ const mapOpenAIStreamToAnthropicSSE = ( let started = options?.emitMessageStart === false; let thinkingStarted = false; let thinkingBlockIndex = -1; - let thinkingText = ''; let textStarted = false; let textBlockIndex = -1; // Tracks how many content blocks (thinking + text) have been opened @@ -1125,28 +1110,16 @@ const mapOpenAIStreamToAnthropicSSE = ( // Anthropic streaming requires each block to be stopped before the next. const closeOpenTextBlocks = (): void => { if (thinkingStarted) { - // Anthropic emits the signature last, just before the block closes, so a - // client that reassembles blocks (SDK `finalMessage()`) ends up with a - // replayable thinking block rather than bare prose. - const signature = sealReasoning(thinkingText); - - if (signature) { - enqueueEvent({ - type: 'content_block_delta', - index: thinkingBlockIndex, - delta: { - type: 'signature_delta', - signature, - }, - }); - } - + // Anthropic emits the signature last, just before the block closes — but + // we do not send one here: the reasoning already went out as + // `thinking_delta`s, and duplicating it into a signature would put the + // text on the wire twice for callers that count it. See + // `buildThinkingBlock`. enqueueEvent({ type: 'content_block_stop', index: thinkingBlockIndex, }); thinkingStarted = false; - thinkingText = ''; } if (textStarted) { @@ -1218,7 +1191,6 @@ const mapOpenAIStreamToAnthropicSSE = ( thinking: reasoningText, }, }); - thinkingText += reasoningText; } // Text content diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index f774ce2..d7be40a 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -39,7 +39,6 @@ import { } from './web-search-loop'; import { resolveRequestAccessKey } from './auth'; import { createErrorResponse } from '../shared/http'; -import { sealReasoning, unsealReasoning } from '../shared/reasoning-seal'; import { createStreamCloser, readTimeoutFrame, @@ -66,7 +65,7 @@ interface ResponsesInputItem { tools?: Array<{ type?: string; name?: string } & Record>; /** * Present on `reasoning` items a client replays from an earlier response. - * Opaque to us — we mint and verify these ourselves, see `reasoning-seal`. + * We put the reasoning here verbatim; clients echo it back untouched. * A compaction item carries the same field. */ encrypted_content?: string; @@ -898,20 +897,31 @@ const stringifyContent = (value: unknown): string => { return JSON.stringify(value); }; +/** + * Marks an `encrypted_content` value we minted, so we can tell it apart from a + * blob issued by someone else. + * + * Not a security measure. Codex never opens this field — it only echoes it — so + * plaintext round-trips fine, but a marker is what stops us from reading a + * genuinely encrypted blob as if it were reasoning text. + */ +const REASONING_PREFIX = 'cbreason1:'; + /** * Pulls readable reasoning out of a replayed `reasoning` item. * - * The sealed blob is authoritative when we can open it. Otherwise the summary - * text is a usable stand-in: the Agents SDK sends summaries back as - * `summary: [{type: 'summary_text', text}]`, so accepting them means a client - * that never got a signature from us still gets its reasoning carried through - * rather than dropped. + * Only values we minted are used: anything else — an OpenAI-issued blob, say — + * is opaque ciphertext, and forwarding it upstream would send gibberish where + * reasoning belongs. The summary is the fallback in that case. + * + * The Agents SDK sends summaries as `summary: [{type: 'summary_text', text}]`, + * so a client that never received our blob still gets its reasoning through. */ const extractReasoningFromItem = (item: ResponsesInputItem): string => { - const sealed = unsealReasoning(item.encrypted_content); + const blob = item.encrypted_content; - if (sealed) { - return sealed; + if (typeof blob === 'string' && blob.startsWith(REASONING_PREFIX)) { + return blob.slice(REASONING_PREFIX.length); } if (!Array.isArray(item.summary)) { @@ -1486,19 +1496,23 @@ const mapChatResponseToResponsesPayload = async ( // Clients replay `output` verbatim on the next turn, so this is what lets a // stateless Responses client carry reasoning forward. Without it the only // reasoning we ever hand back is a transient `reasoning_text.delta`, which no - // client can replay because it has no id and no sealed blob. + // client can replay because it has no id and no blob to send back. + // + // `encrypted_content` holds the reasoning verbatim, not ciphertext. Codex + // never opens it — it only echoes it — so plaintext round-trips exactly as + // well, and encrypting would obscure a value that carries no secret: the + // upstream gave us a summary, and the `summary` field below already shows it. const reasoningText = firstChoice.message?.reasoning_content ?? firstChoice.message?.reasoning ?? ''; - const sealedReasoning = sealReasoning(reasoningText); if (reasoningText) { output.push({ id: createResponseReasoningId(), type: 'reasoning', summary: [{ type: 'summary_text', text: reasoningText }], - ...(sealedReasoning ? { encrypted_content: sealedReasoning } : {}), + encrypted_content: `${REASONING_PREFIX}${reasoningText}`, status: 'completed', }); } @@ -1543,6 +1557,10 @@ const mapChatResponseToResponsesPayload = async ( role: 'assistant', content: getAssistantTranscriptContent(outputText, transcriptToolCalls), ...(transcriptToolCalls ? { tool_calls: transcriptToolCalls } : {}), + // A client that continues via `previous_response_id` rather than + // replaying `output` never sees the reasoning item, so the session is + // the only place the reasoning can survive into the next turn. + ...(reasoningText ? { reasoning: reasoningText } : {}), }, ], defaults, @@ -1617,6 +1635,18 @@ const mapChatStreamToResponsesEventStream = ( }; }); let outputText = ''; + // Reasoning accumulated from stream deltas. The delta events alone are not + // replayable — a client needs a reasoning item in the completed output, with + // a blob of its own, to send anything back on the next turn. + let streamedReasoning = ''; + // Claimed on the first reasoning delta, which lands before any text, so the + // item sorts ahead of the message it produced. + let reasoningOutputIndex: number | null = null; + let reasoningItemAdded = false; + // Fixed when the first reasoning delta arrives, so the `output_item.added` + // event and the completed output reference the same id. + let reasoningItemId = ''; + let nextOutputIndex = serverToolItems.reduce( (maximum, item) => Math.max(maximum, item.outputIndex), @@ -1676,6 +1706,31 @@ const mapChatStreamToResponsesEventStream = ( ], }); + // Carries the reasoning verbatim rather than encrypted — same reasoning as + // the non-streaming item above. + const buildStreamingReasoningItem = (): Record => ({ + id: reasoningItemId, + type: 'reasoning', + summary: [{ type: 'summary_text', text: streamedReasoning }], + encrypted_content: `${REASONING_PREFIX}${streamedReasoning}`, + status: 'completed', + }); + + const ensureReasoningItemAdded = (): void => { + if (reasoningItemAdded) { + return; + } + + reasoningOutputIndex ??= allocateOutputIndex(); + enqueueEvent({ + type: 'response.output_item.added', + item: buildStreamingReasoningItem(), + output_index: reasoningOutputIndex, + response_id: responseId, + }); + reasoningItemAdded = true; + }; + const ensureMessageAdded = (): void => { if (messageAddedEmitted) { return; @@ -1826,6 +1881,12 @@ const mapChatStreamToResponsesEventStream = ( ...(transcriptToolCalls ? { tool_calls: transcriptToolCalls } : {}), + // Same reason as the non-streaming path: a client that + // continues via `previous_response_id` instead of + // replaying `output` would otherwise lose the reasoning. + ...(streamedReasoning + ? { reasoning: streamedReasoning } + : {}), }, ], defaults, @@ -1899,6 +1960,19 @@ const mapChatStreamToResponsesEventStream = ( item: completed, outputIndex, })), + // Streamed reasoning needs the same replayable item the + // non-streaming path emits. It sorts ahead of the message by + // taking the next index before the message claims its own — + // the deltas come first on the wire, so the item order has + // to match or a client replaying `output` scrambles it. + ...(streamedReasoning && reasoningOutputIndex !== null + ? [ + { + item: buildStreamingReasoningItem(), + outputIndex: reasoningOutputIndex, + }, + ] + : []), ...(outputText && messageState.outputIndex !== null ? [ { @@ -2029,6 +2103,9 @@ const mapChatStreamToResponsesEventStream = ( } if (delta?.reasoning_content) { + reasoningItemId ||= createResponseReasoningId(); + streamedReasoning += delta.reasoning_content; + ensureReasoningItemAdded(); enqueueEvent({ type: 'response.reasoning_text.delta', delta: delta.reasoning_content, diff --git a/lib/server/shared/reasoning-seal.ts b/lib/server/shared/reasoning-seal.ts deleted file mode 100644 index 1497c92..0000000 --- a/lib/server/shared/reasoning-seal.ts +++ /dev/null @@ -1,135 +0,0 @@ -import crypto from 'node:crypto'; - -/** - * Reasoning seals. - * - * Anthropic's `thinking` blocks carry a `signature`: an opaque, encrypted copy - * of the full reasoning that a client echoes back unchanged on later turns so - * the model can continue from where it left off. Codex's Responses reasoning - * items carry `encrypted_content` for the same purpose. - * - * We are the API as far as our clients are concerned, so we mint and verify - * these ourselves — nothing here talks to Anthropic. Two properties matter: - * - * - **Opaque and tamper-evident.** The client must not be able to read or - * forge the reasoning, exactly as with the real thing. aes-256-gcm gives - * both; the auth tag makes a modified blob fail to verify rather than - * decrypt to garbage. - * - **Lossless.** Unsealing has to return the reasoning we sealed, because - * that string is what we hand back to the chat upstream. - * - * The key is the same environment secret the storage layer already uses, so - * operators configure one thing. When it is absent we fall back to a plain - * encoding instead of throwing: reasoning round-tripping is an enhancement, - * and a deployment without the secret set should still proxy successfully. - */ - -const SEAL_ENV = 'CODEBUDDY_STORAGE_ENCRYPTION_KEY'; - -/** Prefix marking the unencrypted fallback, so unseal never guesses. */ -const PLAIN_PREFIX = 'cbr1:'; - -/** Prefix marking a sealed blob, carrying the iv and auth tag alongside it. */ -const SEALED_PREFIX = 'cbs1:'; - -const IV_BYTES = 12; -const TAG_BYTES = 16; - -const createSealKey = (): Buffer | null => { - const source = process.env[SEAL_ENV]?.trim(); - - if (!source) { - return null; - } - - return crypto.createHash('sha256').update(source).digest(); -}; - -/** - * Encodes prior-turn reasoning as an opaque value safe to hand to a client. - * - * Returns `undefined` for empty input so callers can omit the field entirely - * rather than emit a signature for nothing — an empty thinking block is - * meaningful to some clients and noise to others. - */ -export const sealReasoning = (reasoning: string): string | undefined => { - if (!reasoning) { - return undefined; - } - - const key = createSealKey(); - - if (!key) { - return `${PLAIN_PREFIX}${Buffer.from(reasoning, 'utf8').toString( - 'base64url', - )}`; - } - - const iv = crypto.randomBytes(IV_BYTES); - const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); - const ciphertext = Buffer.concat([ - cipher.update(Buffer.from(reasoning, 'utf8')), - cipher.final(), - ]); - const tag = cipher.getAuthTag(); - - return `${SEALED_PREFIX}${Buffer.concat([iv, tag, ciphertext]).toString( - 'base64url', - )}`; -}; - -/** - * Recovers reasoning from a value produced by {@link sealReasoning}. - * - * Unrecognised or tampered input yields `undefined` rather than throwing: a - * client may replay a genuine Anthropic signature (which we cannot decrypt) or - * a block from another deployment, and either should degrade to "no prior - * reasoning" instead of failing the request. - */ -export const unsealReasoning = (sealed: unknown): string | undefined => { - if (typeof sealed !== 'string' || !sealed) { - return undefined; - } - - if (sealed.startsWith(PLAIN_PREFIX)) { - try { - const decoded = Buffer.from( - sealed.slice(PLAIN_PREFIX.length), - 'base64url', - ).toString('utf8'); - - return decoded || undefined; - } catch { - return undefined; - } - } - - if (!sealed.startsWith(SEALED_PREFIX)) { - return undefined; - } - - const key = createSealKey(); - - if (!key) { - // Sealed under a key we no longer have (secret rotated away or unset). - return undefined; - } - - try { - const buffer = Buffer.from(sealed.slice(SEALED_PREFIX.length), 'base64url'); - const iv = buffer.subarray(0, IV_BYTES); - const tag = buffer.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); - const encrypted = buffer.subarray(IV_BYTES + TAG_BYTES); - const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); - decipher.setAuthTag(tag); - const plaintext = Buffer.concat([ - decipher.update(encrypted), - decipher.final(), - ]).toString('utf8'); - - return plaintext || undefined; - } catch { - // Auth-tag mismatch (tampered) or malformed base64. - return undefined; - } -}; diff --git a/tests/server/reasoning-roundtrip.test.ts b/tests/server/reasoning-roundtrip.test.ts index 0012db1..99d32f6 100644 --- a/tests/server/reasoning-roundtrip.test.ts +++ b/tests/server/reasoning-roundtrip.test.ts @@ -6,7 +6,6 @@ import { NextRequest } from 'next/server'; import { addCredential } from '@/lib/server/domain/credentials'; import { handleMessagesRequest } from '@/lib/server/proxy/anthropic'; import { handleResponsesRequest } from '@/lib/server/proxy/responses'; -import { sealReasoning } from '@/lib/server/shared/reasoning-seal'; const repoRoot = process.cwd(); const tempRootDir = path.join(repoRoot, '.tmp-test-reasoning-roundtrip'); @@ -36,6 +35,44 @@ const chatResponse = (content: string, reasoning?: string): Response => { headers: { 'Content-Type': 'application/json' } }, ); +const makeSseResponse = (frames: string[]): Response => + new Response(frames.join('\n\n') + '\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream; charset=utf-8' }, + }); + +interface CompletedOutputItem { + type?: string; + encrypted_content?: string; + summary?: Array<{ text?: string }>; +} + +/** Pulls the `response.completed` payload out of an SSE stream. */ +const extractCompletedResponse = ( + payload: string, +): { output?: CompletedOutputItem[] } | undefined => { + for (const line of payload.split('\n')) { + if (!line.startsWith('data: ')) { + continue; + } + + try { + const event = JSON.parse(line.slice(6)) as { + type?: string; + response?: { output?: CompletedOutputItem[] }; + }; + + if (event.type === 'response.completed') { + return event.response; + } + } catch { + // Ignore keepalives and non-JSON frames. + } + } + + return undefined; +}; + /** Captures the body we send upstream, so tests assert on the real payload. */ const captureUpstreamBody = async ( send: () => Promise, @@ -106,7 +143,7 @@ describe('reasoning round trip', () => { }); describe('claude code (/v1/messages)', () => { - it('emits a signature so the thinking block is replayable', async () => { + it('emits the upstream reasoning as a thinking block', async () => { const original = globalThis.fetch; globalThis.fetch = (async () => chatResponse('the answer', 'the model reasoned about primes')) as never; @@ -129,8 +166,9 @@ describe('reasoning round trip', () => { const thinking = content.find((block) => block.type === 'thinking'); expect(thinking?.thinking).toBe('the model reasoned about primes'); - expect(typeof thinking?.signature).toBe('string'); - expect(thinking?.signature).toBeTruthy(); + // No signature: it would duplicate this text on the wire. See + // `buildThinkingBlock` in the proxy. + expect(thinking?.signature).toBeUndefined(); }); it('recovers replayed reasoning and sends it upstream', async () => { @@ -160,11 +198,11 @@ describe('reasoning round trip', () => { expect(assistant?.reasoning).toBe('Claude Code replays this'); }); - it('prefers the sealed signature but falls back to the summary', async () => { + it('does not forward a signature it did not mint', async () => { // A client may replay a genuine Anthropic signature from a session it - // started against real Claude. We cannot open those, and discarding the - // summary over that would lose perfectly good reasoning — so the - // signature is preferred when valid, never a gate on the fallback. + // started against real Claude. That value is ciphertext we cannot read, + // and forwarding it upstream would put gibberish where reasoning + // belongs — so it is skipped and the summary carries the reasoning. const body = await captureUpstreamBody(() => handleMessagesRequest(makeAnthropicRequest(), { max_tokens: 100, @@ -194,9 +232,10 @@ describe('reasoning round trip', () => { expect(assistant?.reasoning).toBe('summary text'); }); - it('prefers the signature over the summary when it opens', async () => { - const sealed = sealReasoning('verbatim reasoning from the signature'); - + it('ignores a signature in favour of the thinking text', async () => { + // We never mint signatures on this path, so any signature — even one + // shaped like ours — is not ours to interpret. The `thinking` field is + // what carries the reasoning. const body = await captureUpstreamBody(() => handleMessagesRequest(makeAnthropicRequest(), { max_tokens: 100, @@ -205,8 +244,8 @@ describe('reasoning round trip', () => { { content: [ { - signature: sealed, - thinking: 'a shorter summary', + signature: 'cbreason1:not from us', + thinking: 'the real reasoning', type: 'thinking', }, { text: 'the answer', type: 'text' }, @@ -223,16 +262,12 @@ describe('reasoning round trip', () => { (m) => m.role === 'assistant', ); - expect(assistant?.reasoning).toBe( - 'verbatim reasoning from the signature', - ); + expect(assistant?.reasoning).toBe('the real reasoning'); }); - it('carries an omitted-display block whose summary is empty', async () => { - // Under `display: "omitted"` the thinking field is empty and the - // signature is the only payload. Replaying it must still work. - const sealed = sealReasoning('reasoning hidden from display'); - + it('drops a block that carries no reasoning', async () => { + // An omitted-display block has empty `thinking`, so there is nothing to + // recover and no reasoning should reach the upstream. const body = await captureUpstreamBody(() => handleMessagesRequest(makeAnthropicRequest(), { max_tokens: 100, @@ -240,7 +275,11 @@ describe('reasoning round trip', () => { { content: 'hi', role: 'user' }, { content: [ - { signature: sealed, thinking: '', type: 'thinking' }, + { + signature: 'cbreason1:reasoning hidden from display', + thinking: '', + type: 'thinking', + }, { text: 'the answer', type: 'text' }, ], role: 'assistant', @@ -255,7 +294,7 @@ describe('reasoning round trip', () => { (m) => m.role === 'assistant', ); - expect(assistant?.reasoning).toBe('reasoning hidden from display'); + expect(assistant?.reasoning).toBeUndefined(); }); it('no longer leaks redacted_thinking into the message body', async () => { @@ -366,6 +405,33 @@ describe('reasoning round trip', () => { expect(assistant?.reasoning).toBe('check the weather'); }); + it('does not forward an encrypted_content it did not mint', async () => { + // An OpenAI-issued blob is ciphertext we cannot read. Forwarding it + // upstream would send gibberish where reasoning belongs, so the summary + // is used instead — the same rule the Anthropic path follows. + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'hi' }, + { + id: 'rs_ext', + encrypted_content: 'gAAAAABoISQ24OyVRYbkYfukdJoqdzWT...', + summary: [{ type: 'summary_text', text: 'from the summary' }], + type: 'reasoning', + }, + { role: 'assistant', content: 'the answer' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('from the summary'); + }); + it('drops a reasoning item that carries nothing recoverable', async () => { const body = await captureUpstreamBody(() => handleResponsesRequest(makeResponsesRequest(), { @@ -383,5 +449,103 @@ describe('reasoning round trip', () => { expect(messages).toHaveLength(2); expect(messages.filter((m) => m.content === '')).toHaveLength(0); }); + + it('emits a replayable reasoning item in the streamed completed output', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + makeSseResponse([ + 'data: {"id":"c1","choices":[{"delta":{"reasoning_content":"thinking hard"}}]}', + 'data: {"id":"c1","choices":[{"delta":{"content":"the answer"}}]}', + 'data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}]}', + 'data: [DONE]', + ]), + ); + + const response = await handleResponsesRequest(makeResponsesRequest(), { + input: 'hi', + model: 'gpt-5.5', + stream: true, + } as never); + + const payload = await response.text(); + const completed = extractCompletedResponse(payload); + + expect(completed).toBeDefined(); + + const reasoning = completed?.output?.find( + (item) => item.type === 'reasoning', + ); + + // Without this the only reasoning a streaming client ever sees is a + // transient delta — nothing it can send back on the next turn. + expect(reasoning).toBeDefined(); + expect(typeof reasoning?.encrypted_content).toBe('string'); + expect(reasoning?.summary?.[0]?.text).toBe('thinking hard'); + }); + + it('orders the streamed reasoning item before the message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + makeSseResponse([ + 'data: {"id":"c1","choices":[{"delta":{"reasoning_content":"think first"}}]}', + 'data: {"id":"c1","choices":[{"delta":{"content":"then answer"}}]}', + 'data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}]}', + 'data: [DONE]', + ]), + ); + + const response = await handleResponsesRequest(makeResponsesRequest(), { + input: 'hi', + model: 'gpt-5.5', + stream: true, + } as never); + + const completed = extractCompletedResponse(await response.text()); + const types = completed?.output?.map((item) => item.type) ?? []; + + // A client replays `output` verbatim, so reasoning has to precede the + // message it produced or the replayed turn is scrambled. + expect(types.indexOf('reasoning')).toBeLessThan(types.indexOf('message')); + }); + + it('persists reasoning so a previous_response_id continuation keeps it', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + choices: [ + { + message: { + content: 'the answer', + reasoning_content: 'reasoning to persist', + }, + }, + ], + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const first = await handleResponsesRequest(makeResponsesRequest(), { + input: 'hi', + model: 'gpt-5.5', + } as never); + const firstJson = (await first.json()) as { id?: string }; + + const body = await captureUpstreamBody( + () => + handleResponsesRequest(makeResponsesRequest(), { + input: [{ role: 'user', content: 'and then?' }], + model: 'gpt-5.5', + previous_response_id: firstJson.id, + } as never), + chatResponse('follow-up'), + ); + + // The client never replayed `output`, so the session is the only thing + // that can carry the reasoning into this turn. + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('reasoning to persist'); + }); }); }); diff --git a/tests/server/reasoning-seal.test.ts b/tests/server/reasoning-seal.test.ts deleted file mode 100644 index a3aa65e..0000000 --- a/tests/server/reasoning-seal.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - sealReasoning, - unsealReasoning, -} from '@/lib/server/shared/reasoning-seal'; - -const KEY = 'test-seal-secret'; - -describe('reasoning seals', () => { - const originalKey = process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; - - afterEach(() => { - if (originalKey === undefined) { - delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; - } else { - process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = originalKey; - } - }); - - describe('with a key configured', () => { - beforeEach(() => { - process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = KEY; - }); - - it('round-trips reasoning', () => { - const sealed = sealReasoning('the model thought about primes'); - - expect(sealed).toBeDefined(); - expect(unsealReasoning(sealed)).toBe('the model thought about primes'); - }); - - it('round-trips unicode and newlines without mangling', () => { - const reasoning = '第一步:检查缓存。\nline two — em dash ✓'; - - expect(unsealReasoning(sealReasoning(reasoning))).toBe(reasoning); - }); - - it('produces an opaque blob that does not contain the plaintext', () => { - const sealed = sealReasoning('super secret reasoning'); - - expect(sealed).toBeDefined(); - expect(sealed).not.toContain('super secret reasoning'); - }); - - it('randomises the iv so sealing twice differs', () => { - expect(sealReasoning('same input')).not.toBe(sealReasoning('same input')); - }); - - it('rejects a tampered blob instead of decrypting to garbage', () => { - const sealed = sealReasoning('original reasoning') ?? ''; - const tampered = `${sealed.slice(0, -2)}${sealed.slice(-2) === 'AA' ? 'BB' : 'AA'}`; - - expect(unsealReasoning(tampered)).toBeUndefined(); - }); - }); - - describe('without a key configured', () => { - beforeEach(() => { - delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; - }); - - it('still round-trips via the plain fallback', () => { - expect(unsealReasoning(sealReasoning('no key here'))).toBe('no key here'); - }); - - it('returns undefined for a blob sealed under a key we no longer have', () => { - process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = KEY; - const sealed = sealReasoning('sealed with a key') ?? ''; - delete process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY; - - expect(unsealReasoning(sealed)).toBeUndefined(); - }); - }); - - describe('degenerate input', () => { - beforeEach(() => { - process.env.CODEBUDDY_STORAGE_ENCRYPTION_KEY = KEY; - }); - - it('returns undefined for empty reasoning so no signature is emitted', () => { - expect(sealReasoning('')).toBeUndefined(); - }); - - it('returns undefined for a foreign signature we cannot open', () => { - // What a client replays when the block came from a real Anthropic call. - expect(unsealReasoning('WaUjzkypQ2mUEVM36O2Txu....')).toBeUndefined(); - }); - - it('returns undefined for non-string and empty input', () => { - expect(unsealReasoning(undefined)).toBeUndefined(); - expect(unsealReasoning(null)).toBeUndefined(); - expect(unsealReasoning(42)).toBeUndefined(); - expect(unsealReasoning('')).toBeUndefined(); - }); - }); -}); From c925528a49d5ebd0f2f07e88051bc763e8b0da4d Mon Sep 17 00:00:00 2001 From: orangeboyChen Date: Thu, 17 Sep 2026 20:10:38 +0800 Subject: [PATCH 3/3] test(proxy): cover the reasoning round-trip branches, drop unreachable merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's patch-branch gate sits at 90%; the previous commit left the changed lines at 89.06% (57/64). Cover the branches that were missing: - Anthropic: several thinking blocks in one assistant turn (interleaved thinking puts one before each tool call) join in order. - Responses: a reasoning item attaching to the following assistant message; summaries holding bare strings; summary entries with no text; a non-string `encrypted_content` falling back to the summary; and one reasoning item emitted for many reasoning deltas rather than one per delta. Also drop the merge that combined a carried reasoning with a `reasoning` field already on the assistant message. `ResponsesInputItem` has no such field, so a client cannot send it and the branch was unreachable — the mapper is the only thing that sets it. Replaced with a plain assignment. Changed-branch coverage is now 98.44% (63/64). --- lib/server/proxy/responses.ts | 8 +- tests/server/reasoning-roundtrip.test.ts | 172 +++++++++++++++++++++++ 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/lib/server/proxy/responses.ts b/lib/server/proxy/responses.ts index d7be40a..9d1a686 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -1336,10 +1336,12 @@ const prepareTranscript = async ( return; } + // Attach any reasoning carried forward from a preceding reasoning item. + // `message.reasoning` is only ever set by the mapper below — clients + // cannot send it, since `ResponsesInputItem` has no such field — so + // there is no pre-existing value to merge with. if (pendingReasoning) { - message.reasoning = message.reasoning - ? `${pendingReasoning}${message.reasoning}` - : pendingReasoning; + message.reasoning = pendingReasoning; pendingReasoning = ''; } diff --git a/tests/server/reasoning-roundtrip.test.ts b/tests/server/reasoning-roundtrip.test.ts index 99d32f6..de33f53 100644 --- a/tests/server/reasoning-roundtrip.test.ts +++ b/tests/server/reasoning-roundtrip.test.ts @@ -198,6 +198,37 @@ describe('reasoning round trip', () => { expect(assistant?.reasoning).toBe('Claude Code replays this'); }); + it('joins reasoning from several thinking blocks in one turn', async () => { + // Interleaved thinking puts a thinking block before each tool call, so + // one assistant turn can carry more than one. Both must reach the + // upstream, in order. + const body = await captureUpstreamBody(() => + handleMessagesRequest(makeAnthropicRequest(), { + max_tokens: 100, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { thinking: 'first thought', type: 'thinking' }, + { text: 'looking', type: 'text' }, + { thinking: 'second thought', type: 'thinking' }, + { text: 'the answer', type: 'text' }, + ], + role: 'assistant', + }, + { content: 'and then?', role: 'user' }, + ], + model: 'claude-sonnet-4-5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('first thoughtsecond thought'); + }); + it('does not forward a signature it did not mint', async () => { // A client may replay a genuine Anthropic signature from a session it // started against real Claude. That value is ciphertext we cannot read, @@ -377,6 +408,112 @@ describe('reasoning round trip', () => { expect(typeof reasoning?.encrypted_content).toBe('string'); }); + it('attaches a carried reasoning to the next assistant message', async () => { + // A reasoning item followed by an assistant message: the reasoning rides + // along with the turn it came from, rather than standing on its own. + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'hi' }, + { + id: 'rs_carry', + encrypted_content: 'cbreason1:carried reasoning', + type: 'reasoning', + }, + { role: 'assistant', content: 'the answer' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const messages = upstreamMessages(body); + + // The reasoning item must not become a turn of its own. + expect(messages).toHaveLength(2); + + const assistant = messages.find((m) => m.role === 'assistant'); + + expect(assistant?.reasoning).toBe('carried reasoning'); + expect(assistant?.content).toBe('the answer'); + }); + + it('reads a string entry in a summary', async () => { + // The Agents SDK sends objects, but a summary of bare strings is + // accepted too. + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'hi' }, + { + id: 'rs_str', + summary: ['plain string reasoning'], + type: 'reasoning', + }, + { role: 'assistant', content: 'the answer' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('plain string reasoning'); + }); + + it('ignores summary entries that carry no text', async () => { + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'hi' }, + { + id: 'rs_junk', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + summary: [42, null] as any, + type: 'reasoning', + }, + { role: 'assistant', content: 'the answer' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + // Nothing recoverable, so the item contributes no reasoning and the turn + // still carries the text. + expect(assistant?.reasoning).toBeUndefined(); + expect(assistant?.content).toBe('the answer'); + }); + + it('falls back to the summary when encrypted_content is not a string', async () => { + const body = await captureUpstreamBody(() => + handleResponsesRequest(makeResponsesRequest(), { + input: [ + { role: 'user', content: 'hi' }, + { + id: 'rs_num', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + encrypted_content: 123 as any, + summary: [{ type: 'summary_text', text: 'from the summary' }], + type: 'reasoning', + }, + { role: 'assistant', content: 'the answer' }, + ], + model: 'gpt-5.5', + } as never), + ); + + const assistant = upstreamMessages(body).find( + (m) => m.role === 'assistant', + ); + + expect(assistant?.reasoning).toBe('from the summary'); + }); + it('attaches replayed reasoning to the assistant turn instead of an empty user turn', async () => { const body = await captureUpstreamBody(() => handleResponsesRequest(makeResponsesRequest(), { @@ -482,6 +619,41 @@ describe('reasoning round trip', () => { expect(reasoning?.summary?.[0]?.text).toBe('thinking hard'); }); + it('emits one reasoning item for several reasoning deltas', async () => { + // Reasoning arrives as many deltas; the item is announced on the first + // and must not be announced again. + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + makeSseResponse([ + 'data: {"id":"c1","choices":[{"delta":{"reasoning_content":"first part"}}]}', + 'data: {"id":"c1","choices":[{"delta":{"reasoning_content":" and second"}}]}', + 'data: {"id":"c1","choices":[{"delta":{"content":"the answer"}}]}', + 'data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}]}', + 'data: [DONE]', + ]), + ); + + const response = await handleResponsesRequest(makeResponsesRequest(), { + input: 'hi', + model: 'gpt-5.5', + stream: true, + } as never); + + const payload = await response.text(); + const completed = extractCompletedResponse(payload); + + const reasoningItems = + completed?.output?.filter((item) => item.type === 'reasoning') ?? []; + + expect(reasoningItems).toHaveLength(1); + expect(reasoningItems[0]?.summary?.[0]?.text).toBe( + 'first part and second', + ); + // One `output_item.added` for the reasoning item, not one per delta. + expect( + (payload.match(/"type":"response\.output_item\.added"/g) ?? []).length, + ).toBe(2); + }); + it('orders the streamed reasoning item before the message', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( makeSseResponse([