diff --git a/lib/server/proxy/anthropic.ts b/lib/server/proxy/anthropic.ts index 38f1900..3db86a6 100644 --- a/lib/server/proxy/anthropic.ts +++ b/lib/server/proxy/anthropic.ts @@ -50,6 +50,15 @@ interface AnthropicContentBlock { name?: string; input?: unknown; thinking?: string; + /** + * 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. */ + 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,29 @@ 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 `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` 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 = block.thinking ?? ''; + + 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 +895,17 @@ const buildAllAnthropicServerToolBlocks = ( ): AnthropicContentBlock[] => executions.flatMap(buildAnthropicServerToolBlocks); +/** + * 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 => ({ + 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 +930,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 +969,7 @@ const mapOpenAIResponseToAnthropic = ( if (!turns) { if (reasoningText) { - contentBlocks.push({ type: 'thinking', thinking: reasoningText }); + contentBlocks.push(buildThinkingBlock(reasoningText)); } if (textContent) { @@ -1050,6 +1110,11 @@ 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 — 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, 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..9d1a686 100644 --- a/lib/server/proxy/responses.ts +++ b/lib/server/proxy/responses.ts @@ -63,6 +63,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. + * We put the reasoning here verbatim; clients echo it back untouched. + * 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 +139,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 +175,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 +897,70 @@ const stringifyContent = (value: unknown): string => { return JSON.stringify(value); }; -const mapInputItemToMessage = (item: ResponsesInputItem): TranscriptMessage => { +/** + * 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. + * + * 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 blob = item.encrypted_content; + + if (typeof blob === 'string' && blob.startsWith(REASONING_PREFIX)) { + return blob.slice(REASONING_PREFIX.length); + } + + 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 +1034,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 +1272,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 +1319,33 @@ 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; + } + + // 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 = pendingReasoning; + pendingReasoning = ''; + } + + transcript.push(message); }); } @@ -1375,6 +1493,32 @@ 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 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 ?? + ''; + + if (reasoningText) { + output.push({ + id: createResponseReasoningId(), + type: 'reasoning', + summary: [{ type: 'summary_text', text: reasoningText }], + encrypted_content: `${REASONING_PREFIX}${reasoningText}`, + status: 'completed', + }); + } + if (outputText || !toolCalls.length) { output.push({ id: createMessageId(), @@ -1415,6 +1559,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, @@ -1489,6 +1637,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), @@ -1548,6 +1708,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; @@ -1698,6 +1883,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, @@ -1771,6 +1962,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 ? [ { @@ -1901,6 +2105,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/tests/server/reasoning-roundtrip.test.ts b/tests/server/reasoning-roundtrip.test.ts new file mode 100644 index 0000000..de33f53 --- /dev/null +++ b/tests/server/reasoning-roundtrip.test.ts @@ -0,0 +1,723 @@ +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'; + +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' } }, + ); + +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, + 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 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; + + 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'); + // 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 () => { + 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('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, + // 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, + 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('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, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { + signature: 'cbreason1:not from us', + thinking: 'the real reasoning', + 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('the real reasoning'); + }); + + 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, + messages: [ + { content: 'hi', role: 'user' }, + { + content: [ + { + signature: 'cbreason1:reasoning hidden from display', + 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).toBeUndefined(); + }); + + 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 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(), { + 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('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(), { + 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); + }); + + 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('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([ + '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'); + }); + }); +});