diff --git a/artifacts/issue-3900-live-cpa-probe.ts b/artifacts/issue-3900-live-cpa-probe.ts new file mode 100644 index 0000000000..3e8e26ebc9 --- /dev/null +++ b/artifacts/issue-3900-live-cpa-probe.ts @@ -0,0 +1,115 @@ +// Live probe for issue #3900, via the CPA proxy configured in +// ~/.gjc/agent/models.yml (fallback credentials — no direct Anthropic key on +// this machine). +// +// Step 1: run a real tool-use turn with thinking enabled and capture the +// genuinely signed thinking block. +// Step 2: tamper the thinking text (signature now mismatches), append the +// tool_result, and continue the turn. Anthropic rejects exactly this shape +// with the "thinking ... cannot be modified" 400; behind CPA it can arrive +// as a statusless SSE error event. Expected: the provider classifies the +// rejection, runs the thinking-replay repair, and the turn recovers. +import * as os from "node:os"; +import * as path from "node:path"; +import { Effort } from "../packages/ai/src/model-thinking"; +import { streamAnthropic } from "../packages/ai/src/providers/anthropic"; +import type { Context, Model, ToolResultMessage, UserMessage } from "../packages/ai/src/types"; + +const modelsYml = await Bun.file(path.join(os.homedir(), ".gjc", "agent", "models.yml")).text(); +const anthropicBlock = /anthropic:\n(?:\s+.+\n?)+?(?=\n\S|$)/.exec(modelsYml)?.[0] ?? ""; +const baseUrl = /baseUrl:\s*(\S+)/.exec(anthropicBlock)?.[1]; +const apiKey = /apiKey:\s*"?([^"\n]+)"?/.exec(anthropicBlock)?.[1]; +if (!baseUrl || !apiKey) throw new Error("models.yml fallback credentials not found"); + +const modelId = process.argv[2] ?? "claude-opus-5"; +const model: Model<"anthropic-messages"> = { + api: "anthropic-messages", + provider: "anthropic", + id: modelId, + name: modelId, + baseUrl, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 32_000, + contextWindow: 200_000, + reasoning: true, + thinking: { mode: "anthropic-adaptive", minLevel: Effort.Minimal, maxLevel: Effort.XHigh }, +}; + +const tools: Context["tools"] = [ + { + name: "ping", + description: "returns pong", + parameters: { type: "object", properties: {}, required: [] } as never, + }, +]; +const user: UserMessage = { + role: "user", + content: "Think briefly about why you must call the ping tool, then call it exactly once.", + timestamp: Date.now(), +}; + +// Step 1: obtain a genuinely signed thinking + tool_use turn. +const firstTurn = await streamAnthropic( + model, + { systemPrompt: ["Use the ping tool when asked."], tools, messages: [user] }, + { apiKey, isOAuth: false, thinkingEnabled: true, effort: "xhigh", maxTokens: 4_096 }, +).result(); +const thinkingBlock = firstTurn.content.find(b => b.type === "thinking"); +const toolCall = firstTurn.content.find(b => b.type === "toolCall"); +if (firstTurn.stopReason !== "toolUse" || !toolCall) { + console.log(JSON.stringify({ step: 1, stopReason: firstTurn.stopReason, error: firstTurn.errorMessage })); + throw new Error("step 1 did not produce a tool_use turn"); +} +const signature = thinkingBlock?.type === "thinking" ? thinkingBlock.thinkingSignature : undefined; +console.log( + JSON.stringify({ + step: 1, + stopReason: firstTurn.stopReason, + hasSignedThinking: !!signature, + signaturePrefix: signature?.slice(0, 12), + }), +); + +// Step 2: tamper the signed thinking text and continue with the tool result. +if (thinkingBlock?.type === "thinking") { + thinkingBlock.thinking = `${thinkingBlock.thinking} [TAMPERED issue #3900]`; +} +const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [{ type: "text", text: "pong" }], + isError: false, + timestamp: Date.now() + 1, +}; +const payloads: string[] = []; +const secondTurn = await streamAnthropic( + model, + { systemPrompt: ["Use the ping tool when asked."], tools, messages: [user, firstTurn, toolResult] }, + { + apiKey, + isOAuth: false, + thinkingEnabled: true, + effort: "xhigh", + maxTokens: 4_096, + onPayload: payload => { + payloads.push(JSON.stringify(payload)); + return undefined; + }, + }, +).result(); + +const report = { + step: 2, + baseUrl, + model: modelId, + requests: payloads.length, + firstRequestHadTamperedThinking: payloads[0]?.includes("TAMPERED issue #3900") ?? false, + lastRequestHadTamperedThinking: payloads.at(-1)?.includes("TAMPERED issue #3900") ?? false, + stopReason: secondTurn.stopReason, + errorMessage: secondTurn.errorMessage, + text: secondTurn.content.filter(b => b.type === "text").map(b => (b as { text: string }).text), +}; +console.log(JSON.stringify(report, null, 2)); +if (secondTurn.stopReason !== "stop") process.exit(1); diff --git a/artifacts/issue-3900-sse-proxy-sim.ts b/artifacts/issue-3900-sse-proxy-sim.ts new file mode 100644 index 0000000000..f9508c39d5 --- /dev/null +++ b/artifacts/issue-3900-sse-proxy-sim.ts @@ -0,0 +1,98 @@ +// Issue #3900 wire-level simulation: a local proxy that behaves like +// CLIProxyAPI — it answers HTTP 200 and delivers Anthropic's 400 body as an +// in-stream SSE `error` event (the exact captured rejection). The second +// request succeeds. Runs the real streamAnthropic + Anthropic SDK transport, +// so it exercises iterateAnthropicEvents' statusless error throw and the +// thinking-replay repair end-to-end without any credentials. +import { streamAnthropic } from "../packages/ai/src/providers/anthropic"; +import type { AssistantMessage, Context, Model, UserMessage } from "../packages/ai/src/types"; + +// `masked` reproduces the live 2026-08-06 CPA capture: the proxy replaces the +// upstream body entirely, so the client only sees a generic `api_error`. +const capturedError = + process.argv[2] === "masked" + ? '{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}' + : '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."}}'; + +const successFrames = [ + ['message_start', '{"type":"message_start","message":{"id":"msg_sim","usage":{"input_tokens":1,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}'], + ['content_block_start', '{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}'], + ['content_block_delta', '{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"recovered"}}'], + ['content_block_stop', '{"type":"content_block_stop","index":0}'], + ['message_delta', '{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":1,"output_tokens":1,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}'], + ['message_stop', '{"type":"message_stop"}'], +] as const; + +const requestBodies: string[] = []; +const server = Bun.serve({ + port: 0, + async fetch(req) { + if (!new URL(req.url).pathname.endsWith("/v1/messages")) return new Response("not found", { status: 404 }); + requestBodies.push(await req.text()); + const frames = + requestBodies.length === 1 + ? [`event: error\ndata: ${capturedError}\n\n`] + : successFrames.map(([event, data]) => `event: ${event}\ndata: ${data}\n\n`); + return new Response(frames.join(""), { + status: 200, + headers: { "content-type": "text/event-stream", "request-id": `req_sim_${requestBodies.length}` }, + }); + }, +}); + +const model: Model<"anthropic-messages"> = { + api: "anthropic-messages", + provider: "anthropic", + id: "claude-opus-5", + name: "claude-opus-5", + baseUrl: `http://127.0.0.1:${server.port}`, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + maxTokens: 8_192, + contextWindow: 200_000, + reasoning: true, +}; +const user: UserMessage = { role: "user", content: "first", timestamp: Date.now() }; +const assistant: AssistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "signed replay thinking", thinkingSignature: "sig_issue_3900" }, + { type: "text", text: "history answer" }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-opus-5", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), +}; +const context: Context = { + messages: [user, assistant, { ...user, content: "next prompt", timestamp: Date.now() + 1 }], +}; + +const result = await streamAnthropic(model, context, { + apiKey: "sk-ant-api-sim", + isOAuth: false, + thinkingEnabled: true, +}).result(); +server.stop(true); + +const report = { + requests: requestBodies.length, + firstRequestHadSignedThinking: requestBodies[0]?.includes("sig_issue_3900") ?? false, + repairedRequestDroppedThinking: requestBodies[1] !== undefined && !requestBodies[1].includes("sig_issue_3900"), + stopReason: result.stopReason, + errorMessage: result.errorMessage, + text: result.content.filter(b => b.type === "text").map(b => (b as { text: string }).text), +}; +console.log(JSON.stringify(report, null, 2)); +if (result.stopReason !== "stop" || requestBodies.length !== 2 || !report.repairedRequestDroppedThinking) { + process.exit(1); +} diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4356e86fc8..1e304f7601 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,9 @@ - Anthropic Sonnet 5 now exposes Anthropic's real `xhigh` and `max` thinking efforts on the Messages API (`minimal`/`low`/`medium`/`high`/`xhigh`/`max`), matching official support. The previous generic `kind === opus` gate excluded it from the full preset range; the capability predicate is now an explicit version-scoped list (Opus 4.7+, Sonnet 5+), so older Sonnet generations and Bedrock Converse routes stay fail-closed at their previously advertised levels (issue #3913). - Alibaba Token Plan now exposes Qwen 3.8 Max under the provider-supported `qwen3.8-max` wire id instead of the rejected `qwen-3.8-max` spelling; catalog regeneration canonicalizes a legacy discovered alias rather than retaining a broken duplicate (#3909). - Canonicalized first-class MiniMax M3 catalog ids (issue #3896). The bundled catalog previously shipped stale lowercase `minimax-m3` duplicates (512K) next to the canonical `MiniMax-M3` (1M) on all four first-class MiniMax providers, plus a non-official `minimax-v3` entry under `minimax-code`. The lowercase `minimax-m3` entries and `minimax-v3` are removed; `MiniMax-M3` is the single canonical first-class id (the regen-safe 1M pin in `applyGeneratedModelPolicy` now keys on `MiniMax-M3` / `MiniMax-M3[1m]` instead of the removed lowercase id), `DEFAULT_MODEL_PER_PROVIDER` points at `MiniMax-M3`, and the official Anthropic Token Plan id `MiniMax-M3[1m]` is first-class on the `minimax` / `minimax-cn` Anthropic routes with 1M context semantics. Unrelated catalog providers keep their own `minimax-m3` contracts. +- Anthropic thinking-replay repair now also triggers when the mutation/signature `invalid_request_error` arrives as a statusless in-stream SSE `error` event (issue #3900). Proxies such as CLIProxyAPI forward the upstream 400 body over an HTTP 200 SSE stream, so the thrown error carries no HTTP status; the classifiers previously required `status === 400` and let the session loop on an unrecoverable replay rejection. Statusless errors still require the full `invalid_request_error` thinking wording, so unrelated transport failures never claim the one-shot repair. +- Anthropic thinking-replay repair now also recovers when a proxy masks the rejection entirely (issue #3900). Live CLIProxyAPI captures replace the upstream 400 body with a generic `{"type":"api_error","message":"An error occurred while processing the request."}` SSE event on an HTTP 200 response, which names no cause and matches no transient phrase, so the turn died on the first attempt. Such a masked rejection now takes the same one-shot latest-then-full-history repair, but only before the first token and only while the request actually replays signed `thinking`/`redacted_thinking` blocks; masked failures on requests without replayed thinking still surface immediately. The classifier is exported as `isAnthropicMaskedProxyRejection`. + - Anthropic cache-control resolution now falls back to `model.cacheRetention` at the provider boundary, preserving configured retention and request-over-model precedence through special dispatch wrappers such as GitLab Duo. A configured `cacheRetention: "none"` can no longer be dropped and replaced by the new automatic Claude-family cache marker. ## [0.12.12] - 2026-08-05 diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index bc15958b9b..ae51529a0b 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -395,8 +395,20 @@ export function isAnthropicFastModeUnsupportedError(error: unknown): boolean { return false; } +/** + * Proxies (e.g. CLIProxyAPI) can deliver Anthropic's 400 body as an in-stream + * SSE `error` event on an HTTP 200 response; the thrown error then carries no + * HTTP status at all (issue #3900). Accept both the direct 400 and the + * statusless SSE shape — the strict `invalid_request_error` message checks in + * each matcher keep the statusless branch from claiming unrelated failures. + */ +function isAnthropicInvalidRequestStatus(error: unknown): boolean { + const status = extractHttpStatusFromError(error); + return status === 400 || status === undefined; +} + export function isAnthropicThinkingBlockMutationError(error: unknown): boolean { - if (extractHttpStatusFromError(error) !== 400) return false; + if (!isAnthropicInvalidRequestStatus(error)) return false; const message = error instanceof Error ? error.message : String(error); return ( /invalid_request_error/i.test(message) && @@ -414,7 +426,7 @@ export function isAnthropicThinkingBlockMutationError(error: unknown): boolean { * than only the latest one. */ export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolean { - if (extractHttpStatusFromError(error) !== 400) return false; + if (!isAnthropicInvalidRequestStatus(error)) return false; const message = error instanceof Error ? error.message : String(error); return ( /invalid_request_error/i.test(message) && @@ -423,6 +435,27 @@ export function isAnthropicThinkingSignatureInvalidError(error: unknown): boolea ); } +/** + * CLIProxyAPI replaces Anthropic's rejection body wholesale instead of forwarding + * it: the client only ever sees + * `{"type":"error","error":{"type":"api_error","message":"An error occurred while + * processing the request."}}`, delivered as an in-stream SSE `error` event on an + * HTTP 200 response, so neither the status nor the message survives. Captured CPA + * traces for that masked shape carry the thinking-integrity 400 upstream (issue + * #3900), and the generic body matches no transient phrase either, so the turn + * dies unrecoverably. Nothing in the payload names the cause; callers must pair + * this with a request that actually replays signed thinking blocks before + * treating it as a thinking-replay rejection. + */ +export function isAnthropicMaskedProxyRejection(error: unknown): boolean { + const status = extractHttpStatusFromError(error); + if (status !== undefined && status !== 400) return false; + const message = error instanceof Error ? error.message : String(error); + // A body that still names its error type is classified by the strict matchers. + if (/invalid_request_error/i.test(message)) return false; + return /"type"\s*:\s*"api_error"/.test(message) && /an error occurred while processing/i.test(message); +} + function hasStrictAnthropicTools(params: MessageCreateParamsStreaming): boolean { const tools = params.tools as Array<{ strict?: unknown }> | undefined; return tools?.some(tool => tool.strict === true) ?? false; @@ -1856,7 +1889,12 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( !options?.fallbackManaged && !repairAllAssistantThinking && firstTokenTime === undefined && - (thinkingSignatureInvalid || isAnthropicThinkingBlockMutationError(streamFailure)) + (thinkingSignatureInvalid || + isAnthropicThinkingBlockMutationError(streamFailure) || + // Masked proxy rejection: unclassifiable on its own, so the replayed + // request shape is the evidence. Without signed thinking blocks in + // flight there is nothing to repair and the error must surface. + (isAnthropicMaskedProxyRejection(streamFailure) && hasNativeThinkingBlocks(params.messages))) ) { // The mutation 400 blames the "latest assistant message", but its cited // `messages.N.content.M` path can point at an EARLIER replayed turn, so the diff --git a/packages/ai/test/anthropic-thinking-immutability.test.ts b/packages/ai/test/anthropic-thinking-immutability.test.ts index 4350c11179..2710ff8527 100644 --- a/packages/ai/test/anthropic-thinking-immutability.test.ts +++ b/packages/ai/test/anthropic-thinking-immutability.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { convertAnthropicMessages, + isAnthropicMaskedProxyRejection, isAnthropicThinkingBlockMutationError, isAnthropicThinkingSignatureInvalidError, } from "@gajae-code/ai/providers/anthropic"; @@ -355,6 +356,33 @@ describe("Anthropic thinking replay 400 classification", () => { expect(isAnthropicThinkingSignatureInvalidError(error)).toBe(false); }); + // Issue #3900: CLIProxyAPI delivers the upstream 400 body as an in-stream SSE + // `error` event on an HTTP 200 response, so the thrown error carries no HTTP + // status. Both matchers must still classify the invalid_request_error payload. + it("classifies the statusless SSE error-event mutation variant", () => { + const sseError = new Error( + '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."}}', + ); + expect(isAnthropicThinkingBlockMutationError(sseError)).toBe(true); + expect(isAnthropicThinkingSignatureInvalidError(sseError)).toBe(false); + }); + + it("classifies the statusless SSE error-event signature variant", () => { + const sseError = new Error( + '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.24: Invalid `signature` in `thinking` block"}}', + ); + expect(isAnthropicThinkingSignatureInvalidError(sseError)).toBe(true); + expect(isAnthropicThinkingBlockMutationError(sseError)).toBe(false); + }); + + it("rejects statusless masked proxy errors without thinking attribution", () => { + const masked = new Error( + '{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}', + ); + expect(isAnthropicThinkingBlockMutationError(masked)).toBe(false); + expect(isAnthropicThinkingSignatureInvalidError(masked)).toBe(false); + }); + it("rejects non-Error inputs and unrelated thinking-config 400s", () => { expect(isAnthropicThinkingSignatureInvalidError(undefined)).toBe(false); expect(isAnthropicThinkingSignatureInvalidError("Invalid `signature` in `thinking` block")).toBe(false); @@ -365,4 +393,43 @@ describe("Anthropic thinking replay 400 classification", () => { ); expect(isAnthropicThinkingSignatureInvalidError(budgetError)).toBe(false); }); + + // The masked classifier carries no thinking evidence of its own — the caller + // pairs it with `hasNativeThinkingBlocks` — so its whole contract is which + // payloads it claims. + describe("masked proxy rejection classifier", () => { + const maskedBody = + '{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}'; + + it("claims the statusless masked body and its passthrough 400 form", () => { + expect(isAnthropicMaskedProxyRejection(new Error(maskedBody))).toBe(true); + expect(isAnthropicMaskedProxyRejection(status400(`400 ${maskedBody}`))).toBe(true); + }); + + it("leaves a forwarded invalid_request_error body to the strict matchers", () => { + const forwarded = new Error( + '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified."}}', + ); + expect(isAnthropicMaskedProxyRejection(forwarded)).toBe(false); + }); + + it("does not claim non-400 statuses", () => { + const serverError = Object.assign(new Error(maskedBody), { status: 500 }); + expect(isAnthropicMaskedProxyRejection(serverError)).toBe(false); + const rateLimited = Object.assign(new Error(maskedBody), { status: 429 }); + expect(isAnthropicMaskedProxyRejection(rateLimited)).toBe(false); + }); + + it("does not claim other statusless api_error payloads", () => { + const overloaded = new Error('{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}'); + expect(isAnthropicMaskedProxyRejection(overloaded)).toBe(false); + const otherApiError = new Error('{"type":"error","error":{"type":"api_error","message":"Internal error."}}'); + expect(isAnthropicMaskedProxyRejection(otherApiError)).toBe(false); + }); + + it("rejects non-Error inputs", () => { + expect(isAnthropicMaskedProxyRejection(undefined)).toBe(false); + expect(isAnthropicMaskedProxyRejection(null)).toBe(false); + }); + }); }); diff --git a/packages/ai/test/anthropic-thinking-repair-retry.test.ts b/packages/ai/test/anthropic-thinking-repair-retry.test.ts index b54f49f844..52e3f5db06 100644 --- a/packages/ai/test/anthropic-thinking-repair-retry.test.ts +++ b/packages/ai/test/anthropic-thinking-repair-retry.test.ts @@ -102,6 +102,33 @@ function createAnthropicSignatureInvalid400(): MockAnthropicRequest { }; } +// Issue #3900: proxies like CLIProxyAPI forward the upstream 400 body as an +// in-stream SSE `error` event on an HTTP 200 response. The provider throws +// `new Error(sse.data)` with no HTTP status attached. +function createStatuslessSseThinkingMutationError(): MockAnthropicRequest { + return { + async withResponse() { + throw new Error( + '{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified. These blocks must remain as they were in the original response."}}', + ); + }, + }; +} + +// Issue #3900, live CPA capture (2026-08-06): the proxy does not forward the +// upstream body at all. The client only sees a generic `api_error` SSE event on +// an HTTP 200 response, so the rejection carries neither a status nor any hint +// of the thinking-integrity 400 that CPA logged upstream. +function createMaskedProxyRejection(): MockAnthropicRequest { + return { + async withResponse() { + throw new Error( + '{"type":"error","error":{"type":"api_error","message":"An error occurred while processing the request."}}', + ); + }, + }; +} + function makeSignedAssistant(suffix: string, text: string): AssistantMessage { return { role: "assistant", @@ -176,6 +203,156 @@ describe("Anthropic thinking replay repair retry", () => { expect(JSON.stringify(requestBodies[1])).toContain("visible answer"); }); + // Issue #3900: behind CLIProxyAPI the same mutation rejection arrives as a + // statusless SSE error event, and the repair path used to reject it because + // the classifier required an HTTP 400 status. + it("repairs thinking replay when the mutation error arrives statusless via a proxy SSE error event", async () => { + const user: UserMessage = { + role: "user", + content: "first", + timestamp: Date.now(), + }; + const context: Context = { + messages: [ + user, + makeSignedAssistant("proxied", "proxied answer"), + { ...user, content: "next prompt", timestamp: Date.now() + 1 }, + ], + }; + const requestBodies: unknown[] = []; + let attempt = 0; + const create = ((body: unknown) => { + requestBodies.push(body); + attempt += 1; + return (attempt === 1 ? createStatuslessSseThinkingMutationError() : createSuccessfulRequest()) as never; + }) as unknown as Anthropic["messages"]["create"]; + const client = { messages: { create } } as Anthropic; + + const result = await streamAnthropic(model, context, { client }).result(); + + expect(result.stopReason).toBe("stop"); + expect(result.content).toEqual([{ type: "text", text: "recovered" }]); + expect(requestBodies).toHaveLength(2); + expect(JSON.stringify(requestBodies[0])).toContain("sig_proxied"); + expect(JSON.stringify(requestBodies[1])).not.toContain("sig_proxied"); + }); + + // Issue #3900 recurrence: CPA masks the upstream 400 body entirely, so the + // message-based matchers cannot fire. The replayed request shape is the only + // remaining evidence that a thinking-replay repair is worth one retry. + it("repairs thinking replay when the proxy masks the rejection as a generic api_error", async () => { + const user: UserMessage = { + role: "user", + content: "first", + timestamp: Date.now(), + }; + const context: Context = { + messages: [ + user, + makeSignedAssistant("masked", "masked answer"), + { ...user, content: "next prompt", timestamp: Date.now() + 1 }, + ], + }; + + const requestBodies: unknown[] = []; + let attempt = 0; + const create = ((body: unknown) => { + requestBodies.push(body); + attempt += 1; + return (attempt === 1 ? createMaskedProxyRejection() : createSuccessfulRequest()) as never; + }) as unknown as Anthropic["messages"]["create"]; + const client = { messages: { create } } as Anthropic; + + const result = await streamAnthropic(model, context, { client }).result(); + + expect(result.stopReason).toBe("stop"); + expect(result.content).toEqual([{ type: "text", text: "recovered" }]); + expect(requestBodies).toHaveLength(2); + expect(JSON.stringify(requestBodies[0])).toContain("sig_masked"); + expect(JSON.stringify(requestBodies[1])).not.toContain("sig_masked"); + }); + + it("escalates to a full-history repair when the masked rejection survives the latest-only repair", async () => { + const user: UserMessage = { + role: "user", + content: "first", + timestamp: Date.now(), + }; + const context: Context = { + messages: [ + user, + makeSignedAssistant("early", "early answer"), + { ...user, content: "second", timestamp: Date.now() + 1 }, + makeSignedAssistant("late", "late answer"), + { ...user, content: "next prompt", timestamp: Date.now() + 2 }, + ], + }; + + const requestBodies: unknown[] = []; + let attempt = 0; + const create = ((body: unknown) => { + requestBodies.push(body); + attempt += 1; + return (attempt <= 2 ? createMaskedProxyRejection() : createSuccessfulRequest()) as never; + }) as unknown as Anthropic["messages"]["create"]; + const client = { messages: { create } } as Anthropic; + + const result = await streamAnthropic(model, context, { client }).result(); + + expect(result.stopReason).toBe("stop"); + expect(requestBodies).toHaveLength(3); + const secondBody = JSON.stringify(requestBodies[1]); + expect(secondBody).toContain("sig_early"); + expect(secondBody).not.toContain("sig_late"); + const thirdBody = JSON.stringify(requestBodies[2]); + expect(thirdBody).not.toContain("sig_early"); + expect(thirdBody).not.toContain("sig_late"); + }); + + // The masked body says nothing, so the guard must be the request: with no + // replayed thinking blocks the failure is somebody else's and retrying would + // only hide it behind a second identical request. + it("surfaces a masked proxy rejection when the request replays no thinking blocks", async () => { + const user: UserMessage = { + role: "user", + content: "first", + timestamp: Date.now(), + }; + const assistant: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "plain answer" }], + api: "anthropic-messages", + provider: "anthropic", + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const context: Context = { + messages: [user, assistant, { ...user, content: "next prompt", timestamp: Date.now() + 1 }], + }; + + const requestBodies: unknown[] = []; + const create = ((body: unknown) => { + requestBodies.push(body); + return createMaskedProxyRejection() as never; + }) as unknown as Anthropic["messages"]["create"]; + const client = { messages: { create } } as Anthropic; + + const result = await streamAnthropic(model, context, { client }).result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("api_error"); + expect(requestBodies).toHaveLength(1); + }); + // Real captured session failure (2026-07-29): the mutation 400 says "latest // assistant message" but cites `messages.1.content.1` — a HISTORICAL turn — so the // latest-only repair is rejected identically and the turn used to die.