diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 159886a85e0..eb1b9b9d14a 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -962,7 +962,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | | `tool_reference` in a tool result | Text `Tool loaded: ` in the paired result; preserves loaded-tool names without adding declarations or enabling translated server-side deferral | | `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | -| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. Each search or fetch comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result` | +| Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`). On a route to `api.anthropic.com`, the typed `web_search_*` and `web_fetch_*` declarations are also sent to Anthropic as declared and run server-side, unless the web-search sidecar supplies `web_search` itself. A search or fetch with a matching call and complete input comes back to the client as its `server_tool_use` block and its `web_search_tool_result` or `web_fetch_tool_result`, verbatim. Orphan results are dropped without synthesizing a call, while the surrounding text is preserved. When the client sends a complete pair back in its history, the next `api.anthropic.com` request carries it in place only if the following block uniquely identifies its original turn. Repeated anchors, unmatched or duplicate call/result ids, malformed call input, or a result appearing before its call drop the exchange. Call input must be an object with a nonempty search query or fetch URL string; other destinations never see the exchange | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 140bd336f3b..b71e9a5b090 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -522,13 +522,13 @@ function streamedToolArgumentsParse(assembled: string): boolean { } } -/** A string field of a server tool's streamed input (web_search `query`, web_fetch `url`); "" when it did not parse. */ -function serverToolInputField(json: string, key: "query" | "url"): string { +/** A server tool's complete streamed input; undefined when it cannot be recovered. */ +function serverToolInput(json: string): Record | undefined { try { - const value = (JSON.parse(json) as Record | null)?.[key]; - return typeof value === "string" ? value : ""; + const input = JSON.parse(json) as unknown; + return isAnthropicRecord(input) ? input : undefined; } catch { - return ""; + return undefined; } } @@ -735,7 +735,16 @@ function orphanToolResultContent(msg: OcxToolResultMessage): string | unknown[] function messagesToAnthropicFormat( parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }, + serverReplay?: ReadonlyMap[]>, ): { system: string | undefined; messages: unknown[] } { + // The client's server-tool exchanges go back ahead of the block they preceded. Their anchors are + // unique in the caller's history (replayedAnthropicServerBlocks drops repeated ones); each is used once. + const pendingReplay = new Map(serverReplay); + const replayed = (anchor: string) => { + const blocks = pendingReplay.get(anchor) ?? []; + pendingReplay.delete(anchor); + return blocks; + }; // One allocator for the whole request: a tool_result must resolve to the SAME wire id its // call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed // first so a rewritten id can never squat on an id another call legitimately owns. @@ -789,9 +798,13 @@ function messagesToAnthropicFormat( for (const part of aMsg.content) { if (part.type === "text") { const text = (part as OcxTextContent).text; + for (const b of replayed(`text:${text}`)) appendPreface(b); if (text) appendPreface({ type: "text", text }); } else if (part.type === "thinking") { const t = part as OcxThinkingContent; + // Server blocks that preceded this reasoning keep their place, even when it is dropped below. + for (const data of t.redacted ?? []) for (const b of replayed(`redacted:${data}`)) appendPreface(b); + for (const b of replayed(`thinking:${t.signature}`)) appendPreface(b); // History minted under another serving identity (or already rejected as opaque) is not // this destination's to verify: drop its opaque blocks, as the Responses passthrough does. if (parsed._stripReasoningEncryptedContent === true) continue; @@ -806,6 +819,7 @@ function messagesToAnthropicFormat( } } else if (part.type === "toolCall") { const tc = part as OcxToolCall; + for (const b of replayed(`call:${tc.id}`)) appendPreface(b); const flatName = namespacedToolName(tc.namespace, tc.name); // Normalized here, and identically for the matching tool_result above, so a history // replayed from another provider path keeps its call/result pairing (#1767). @@ -998,7 +1012,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)"); } - const { system, messages } = messagesToAnthropicFormat(parsed, toolNames); + // Only api.anthropic.com runs Anthropic's server tools, so only it gets their history back. + const { system, messages } = messagesToAnthropicFormat(parsed, toolNames, + usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined); // Before image normalization, so the framing block is present for every downstream pass. if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages); // Primary image layer: resize/re-encode to fit Anthropic limits without dropping @@ -1336,36 +1352,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // no result in this response and must not surface as a completed search. serverSearch = { id: block.id, json: "", streaming: true }; } - if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") { + if ((block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && typeof block.tool_use_id === "string") { + // The begin/end pair the sidecar emits, plus both blocks verbatim so the Claude client holds a + // replayable copy. Without this response's matching call and complete input, drop the + // exchange instead of inventing a call that could poison the client's next request. + const fetch = block.type === "web_fetch_tool_result"; + const input = serverSearch?.id === block.tool_use_id ? serverToolInput(serverSearch.json) : undefined; + if (!input) break; const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined; - // The begin/end pair the sidecar emits. A result whose call came in an earlier response has no query here. - const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : ""; yield { type: "web_search_call_begin", id: block.tool_use_id }; yield { type: "web_search_call_end", id: block.tool_use_id, - queries: query ? [query] : [], - // A failed search carries one error object instead of the hit list. - status: hits ? "completed" : "failed", + queries: typeof input.query === "string" && input.query.length > 0 ? [input.query] : [], + // A failed call carries one error object instead of the hit list / fetched page. + status: (fetch ? (block.content as { type?: unknown } | undefined)?.type === "web_fetch_result" : hits) ? "completed" : "failed", sources: (hits ?? []).flatMap(h => typeof h.url === "string" ? [{ url: h.url, ...(typeof h.title === "string" ? { title: h.title } : {}) }] : []), - }; - serverSearch = undefined; - } - if (block.type === "web_fetch_tool_result" && typeof block.tool_use_id === "string") { - // Relayed as the same cell, opened at the result for the same reason; the result is - // carried verbatim (a web_fetch_result document or a web_fetch_tool_error). - const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" }; - const url = (result as { url?: unknown }).url; - yield { type: "web_search_call_begin", id: block.tool_use_id }; - yield { - type: "web_search_call_end", - id: block.tool_use_id, - queries: [], - status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed", - fetch: { - url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "", - result, + anthropic: { + use: { type: "server_tool_use", id: block.tool_use_id, name: fetch ? "web_fetch" : "web_search", input }, + result: block as Record, }, }; serverSearch = undefined; @@ -1415,7 +1421,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti yield { type: "tool_call_delta", arguments: delta.partial_json }; } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming && serverSearch.json.length < 4096) { - // Capped: an input past 4 KiB loses its label (query / url), never the call. + // Capped: input that cannot be recovered drops its exchange instead of inventing a call. serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length); } break; diff --git a/src/bridge/internal.ts b/src/bridge/internal.ts index 45e70a610c4..4c2f7e2352d 100644 --- a/src/bridge/internal.ts +++ b/src/bridge/internal.ts @@ -1,5 +1,6 @@ import type { AdapterEvent, + AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, @@ -186,9 +187,12 @@ export function adapterFailureFromEvent(event: Extract { +export function webSearchAction(queries: string[], anthropic?: AnthropicServerBlocks): Record { // Anthropic's server-side web_fetch opens one page instead of searching. - if (fetch) return { type: "open_page", url: fetch.url }; + if (anthropic?.use.name === "web_fetch") { + const url = (anthropic.result.content as { url?: unknown } | undefined)?.url ?? (anthropic.use.input as { url?: unknown }).url; + return { type: "open_page", url: typeof url === "string" ? url : "" }; + } const first = queries[0] ?? ""; return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] }; } diff --git a/src/bridge/response-json.ts b/src/bridge/response-json.ts index 6ac31fb3e24..72d0bdbc0f1 100644 --- a/src/bridge/response-json.ts +++ b/src/bridge/response-json.ts @@ -547,9 +547,9 @@ function buildResponseJSONWithBudget( const safeSources = safeWebSearchSources(e.sources); pushOutput({ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed", - action: webSearchAction(e.queries, e.fetch), + action: webSearchAction(e.queries, e.anthropic), ...(safeSources.length > 0 ? { sources: safeSources } : {}), - ...(e.fetch ? { fetch_result: e.fetch.result } : {}), + ...(e.anthropic ? { anthropic_blocks: e.anthropic } : {}), }); if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 4f929edceef..79bd5a4c4db 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -1,6 +1,7 @@ import { anthropicProgressItems } from "../responses/anthropic-progress"; import type { AdapterEvent, + AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, @@ -685,15 +686,15 @@ export function bridgeToResponsesSSE( // leaves a "Searching the web" spinner spinning forever. // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so // downstream translators (claude outbound) can fill web_search_tool_result content. - // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result - // verbatim as `fetch_result`, for the same claude outbound translator. - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => { + // `anthropic` (Anthropic's server-side call) rides verbatim as `anthropic_blocks`, for the same + // claude outbound translator; a web_fetch makes the cell a page open. + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => { if (!currentWebSearch) return; const item = { type: "web_search_call", id: currentWebSearch.itemId, status, - action: webSearchAction(queries, fetch), + action: webSearchAction(queries, anthropic), ...(sources && sources.length > 0 ? { sources } : {}), - ...(fetch ? { fetch_result: fetch.result } : {}), + ...(anthropic ? { anthropic_blocks: anthropic } : {}), }; emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -1231,7 +1232,7 @@ export function bridgeToResponsesSSE( currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex }; } const safeSources = safeWebSearchSources(event.sources); - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.anthropic); // Queue this search's sources for the next assistant message (dedup by URL). if (safeSources.length > 0) { for (const source of safeSources) { diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 1358de6e1a8..25139ea20f4 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -87,6 +87,72 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { return tools.length > 0 ? tools : undefined; } +/** Validate original call/result order without inventing inputs or pairing a later call backwards. */ +function replayableServerRun(run: readonly Rec[]): boolean { + const pending = new Map(); + const completed = new Set(); + for (const block of run) { + if (block.type === "server_tool_use") { + const { id, name, input } = block; + if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id) + || (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false; + const prototype = Object.getPrototypeOf(input); + const required = input[name === "web_search" ? "query" : "url"]; + if ((prototype !== Object.prototype && prototype !== null) + || typeof required !== "string" || required.trim().length === 0) return false; + pending.set(id, block); + } else { + const id = block.tool_use_id; + if (typeof id !== "string") return false; + const call = pending.get(id); + if (!call || block.type !== `${call.name}_tool_result` + || (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false; + pending.delete(id); + completed.add(id); + } + } + return completed.size > 0 && pending.size === 0; +} + +/** + * Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` / + * `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so + * an Anthropic destination re-inserts them out of body, keyed by the block that follows each run + * in its message: `call:`, `text:`, `thinking:` or + * `redacted:`, all of which survive translation. An anchor that occurs more than once in the + * assistant history (string content included) cannot name one owner, so its run stays dropped, + * as it was before replay existed. A run with none, an unpaired call, a + * non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped. + */ +export function replayedAnthropicServerBlocks(raw: unknown): Map | undefined { + if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined; + const out = new Map(); + const seen = new Map(); + const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1); + for (const message of raw.messages) { + if (!isRec(message) || message.role !== "assistant") continue; + if (typeof message.content === "string") count(`text:${message.content}`); + if (!Array.isArray(message.content)) continue; + let run: Rec[] = []; + for (const block of message.content.filter(isRec)) { + if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") { + // The caller's breakpoints are dropped on this lane, as for the declared tools above. + run.push({ ...block, cache_control: undefined }); + continue; + } + const anchor = block.type === "tool_use" ? `call:${String(block.id)}` + : block.type === "text" ? `text:${String(block.text)}` + : block.type === "thinking" ? `thinking:${String(block.signature)}` + : block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined; + if (anchor) count(anchor); + if (anchor && replayableServerRun(run)) out.set(anchor, run); + run = []; + } + } + for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor); + return out.size > 0 ? out : undefined; +} + export function toolChoiceToResponses(choice: unknown, body: Rec): void { if (!isRec(choice)) return; if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false; diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index c920d9acdb8..19c92d43603 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -223,16 +223,24 @@ export function sanitizeWebSearchInput(input: unknown): Rec { /** * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use * input (query/queries) and the web_search_tool_result content (hits, or the error - * object when the search failed). An item carrying `fetch_result` is Anthropic's - * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim. - * Shared by the SSE and JSON translation paths. + * object when the search failed). An item carrying `anthropic_blocks` came from Anthropic's + * own server-side call and maps back to those blocks verbatim (`srvtoolu_` id, + * `encrypted_content`, web_fetch), so the client can replay them on its next turn. + * Shared by the SSE, JSON and direct translation paths. */ export function webSearchPairFromItem(item: Rec): { - id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean; + id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean; } { const action = isRec(item.action) ? item.action : {}; - const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; const completed = item.status !== "failed"; + const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {}; + if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input) + && (use.name === "web_search" || use.name === "web_fetch") + && result.tool_use_id === use.id && result.type === `${use.name}_tool_result`) { + return { id: use.id, name: use.name, resultType: result.type, input: use.input, resultContent: result.content, completed }; + } + const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; + // An item emitted before `anthropic_blocks` carried a server web_fetch as `fetch_result`. if (item.fetch_result !== undefined) { const input = { url: typeof action.url === "string" ? action.url : "" }; return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed }; diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index bb1be5ad2d9..cf240923bef 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -28,7 +28,7 @@ * completed-response effects belong to the caller, which folds the same events with * `buildResponseJSON` at the terminal through `beforeTerminal`. */ -import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; +import type { AdapterEvent, AnthropicServerBlocks, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "../../types"; import { normalizeDeclaredToolName } from "../../types"; import { coerceIntegerToolArguments } from "../../lib/tool-argument-integers"; import { classifyError, isCyberPolicyCode, type OcxErrorPayload } from "../../lib/errors"; @@ -61,7 +61,7 @@ export interface ClientWebSearch { status: "completed" | "failed"; queries: string[]; sources: { url: string; title?: string }[]; - fetch?: { url: string; result: unknown }; + anthropic?: AnthropicServerBlocks; } /** @@ -412,9 +412,9 @@ export function encodeAdapterEventStream( budget.closeCall(call.callId); currentToolCall = null; }; - const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: ClientWebSearch["fetch"]) => { + const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => { if (!currentWebSearch) return; - writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(fetch ? { fetch } : {}) }); + writer.webSearchDone({ itemId: currentWebSearch.itemId, status, queries, sources: sources ?? [], ...(anthropic ? { anthropic } : {}) }); currentWebSearch = null; }; const appendToolArgs = (call: OpenToolCall, fragment: string) => { @@ -689,7 +689,7 @@ export function encodeAdapterEventStream( if (currentWebSearch) closeCurrentWebSearch("completed", []); currentWebSearch = { itemId: `ws_${uuid()}`, eventId: event.id }; } - closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.fetch); + closeCurrentWebSearch(event.status ?? "completed", event.queries, safeWebSearchSources(event.sources), event.anthropic); break; } case "done": { diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index 9aee92aee3b..9eee75fdb15 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -345,9 +345,9 @@ function createAnthropicMessageWriter( type: "web_search_call", id: search.itemId, status: search.status, - action: webSearchAction(search.queries, search.fetch), + action: webSearchAction(search.queries, search.anthropic), ...(search.sources.length > 0 ? { sources: search.sources } : {}), - ...(search.fetch ? { fetch_result: search.fetch.result } : {}), + ...(search.anthropic ? { anthropic_blocks: search.anthropic } : {}), }); const toolIndex = blockIndex++; emit("content_block_start", { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index d450d49ba5d..2d06e4e4db8 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -33,7 +33,7 @@ import { resolveAlias, claudeCodeNativeAlias, legacyAliasForNative } from "../cl import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; -import { declaredAnthropicServerTools } from "../claude/inbound-content-options"; +import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options"; import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; import { classifyInterceptClient } from "../claude/intercept/client-class"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; @@ -1133,6 +1133,7 @@ async function handleClaudeMessagesWithBudget( nativeReasoningMint, // Out of body like the replay above: the core hands these to an Anthropic destination only. anthropicServerTools: declaredAnthropicServerTools(anthropicBody), + anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody), claudeGoAffinity: { sessionLane: claudeGoSessionLane }, claudeNativeSessionId, stripClaudeMainAuthForNoncanonicalForward: true, diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index daac9b1cebe..3ee37545491 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -177,6 +177,8 @@ export interface HandleResponsesOptions { nativeReasoningMint?: { owner?: NativeReasoningOwner }; /** The Claude client's typed `web_search_*` / `web_fetch_*` declarations; kept only for `inboundWire: "anthropic"`. */ anthropicServerTools?: readonly Record[]; + /** The Claude client's replayable server-tool exchanges (`replayedAnthropicServerBlocks`); same scope. */ + anthropicServerReplay?: ReadonlyMap[]>; /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */ droidDefaultEffort?: string; /** PF-07: the Chat source a combo child may send natively; set only by the Chat ingress. */ diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 71f1ec132e1..e7ebf8eedf8 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -476,6 +476,7 @@ export async function prepareResponsesRequest( parsed._nativeReasoningReplay = options.nativeReasoningReplay; parsed._nativeReasoningMint = options.nativeReasoningMint; parsed._anthropicServerTools = options.anthropicServerTools; + parsed._anthropicServerReplay = options.anthropicServerReplay; } parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; // The body may have been rebuilt since the inbound observation (previous-response diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index 9b29aae01c4..e901594e401 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -90,6 +90,7 @@ export function createResponsesEffects( delete parsed.context.tools; delete parsed._webSearch; delete parsed._anthropicServerTools; + delete parsed._anthropicServerReplay; delete parsed.options.toolChoice; delete parsed.options.parallelToolCalls; // The compaction turn is a plain prose summary; a surviving structured-output format diff --git a/src/types.ts b/src/types.ts index 2abde60e2af..f94998ed276 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,7 @@ export type { OcxProviderContinuationOwner, OcxProviderContinuationState, AdapterEvent, + AnthropicServerBlocks, OcxUrlCitation, OcxUsage, } from "./types/request"; diff --git a/src/types/request.ts b/src/types/request.ts index 229d42c9ed5..6e014690b6c 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,11 @@ export interface OcxParsedRequest { * them, and only for api.anthropic.com; every other destination never sees them. */ _anthropicServerTools?: readonly Record[]; + /** + * Completed server-tool exchanges from the Claude client's history, keyed by the block that + * followed them (`call:` / `text:`). Same audience as `_anthropicServerTools`. + */ + _anthropicServerReplay?: ReadonlyMap[]>; /** * Boundary between replayed history and this turn's newly appended input. Usually the * items the proxy restored from local previous_response_id state; also set when the @@ -414,6 +419,9 @@ export interface OcxProviderContinuationState { [provider: string]: Record | undefined; } +/** One Anthropic server-side call as the API returned it: `server_tool_use` + its `*_tool_result`. */ +export interface AnthropicServerBlocks { use: Record; result: Record } + export type AdapterEvent = | { type: "heartbeat"; replayUnsafe?: true; preflightReady?: true } | { type: "text_delta"; text: string; phase?: OcxMessagePhase; @@ -443,8 +451,8 @@ export type AdapterEvent = | { type: "web_search_call_begin"; id: string } | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[]; - /** Anthropic's server-side web_fetch (adapters/anthropic.ts): the opened page and its `web_fetch_tool_result` content, verbatim. */ - fetch?: { url: string; result: unknown }; + /** Anthropic's server-side call (adapters/anthropic.ts): its `server_tool_use` and result block, verbatim. */ + anthropic?: AnthropicServerBlocks; } | { type: "done"; diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 32f81b346b2..ad90f16699d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -569,7 +569,7 @@ Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_ `src/claude/inbound.ts` bounds the text-carrier skill-directory probe to 4,096 UTF-16 code units, plus one character to recognize the terminating newline. A longer first line is preserved intact instead of being scanned or stubbed; normal POSIX, Windows, mixed and UNC separators retain their basename matching. The existing 10,000-character payload threshold and `claudeCode.blockedSkills` policy remain: `claude-api` is blocked by default, and an explicit empty list disables elision. Native Anthropic passthrough and tool-call/result pairing are unchanged. `tests/claude-integration/claude-inbound.test.ts` covers the exact 4,096/4,097 boundary and a long newline-free carrier. -`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. The same module's `declaredAnthropicServerTools` reads the caller's typed `web_search_*` / `web_fetch_*` declarations unchanged; `src/server/claude-messages.ts` hands them to the Responses core out of body, like the native reasoning replay, and the core keeps them on `parsed._anthropicServerTools` only for `inboundWire: "anthropic"`, so the translated body and every non-Anthropic leg stay as before. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. The translated Messages lane preserves the caller's output cap for non-canonical Responses gateways; canonical ChatGPT parameter stripping remains in its adapter. An explicit operator `cacheRetention` wins; otherwise `src/claude/inbound.ts` selects long retention only when every declared caller breakpoint requests one hour, including nested content and top-level automatic caching. Mixed TTLs retain the default. +`src/claude/inbound-content-options.ts` strips Claude Code's leading `x-anthropic-billing-header:` line from a string system prompt or from the first text block of a system array before it becomes Responses `instructions`, dropping a block left empty. The line's `cch` value rotates per request, so keeping it made the translated prefix and the system-derived fallback `prompt_cache_key` change every turn (#6627). The match is anchored at the prompt start, like the Antigravity strip in `src/adapters/google.ts`; native Anthropic passthrough does not use this translation and keeps the client preamble. `tests/claude-integration/claude-inbound.test.ts` covers string and array systems, header-only blocks, later mentions and key stability. The same module's `declaredAnthropicServerTools` reads the caller's typed `web_search_*` / `web_fetch_*` declarations unchanged; `src/server/claude-messages.ts` hands them to the Responses core out of body, like the native reasoning replay, and the core keeps them on `parsed._anthropicServerTools` only for `inboundWire: "anthropic"`, so the translated body and every non-Anthropic leg stay as before. `replayedAnthropicServerBlocks` does the same for the completed `server_tool_use` + `web_search_tool_result` / `web_fetch_tool_result` runs in the caller's assistant history, which the translated body drops: each run is keyed by the block that follows it (a `tool_use` id, text, a thinking signature or redacted thinking data); an anchor that repeats anywhere in the assistant history, string content included, names no single owner, so its run stays dropped. Each result must follow exactly one matching server call in the same assistant turn, with the same id and tool type; duplicate ids, orphan results and reversed pairs stay dropped. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string. Validation walks the original block sequence once and never pairs a result with a later call. The adapter also drops a streamed result whose matching call or complete input is unavailable, preserving the text without synthesizing an empty-input call. The map travels as `parsed._anthropicServerReplay`. `tests/claude-integration/claude-translated-server-tools.test.ts` covers the Anthropic wire, a third-party Anthropic-compatible endpoint and a GPT leg. The translated Messages lane preserves the caller's output cap for non-canonical Responses gateways; canonical ChatGPT parameter stripping remains in its adapter. An explicit operator `cacheRetention` wins; otherwise `src/claude/inbound.ts` selects long retention only when every declared caller breakpoint requests one hour, including nested content and top-level automatic caching. Mixed TTLs retain the default. `src/claude/outbound.ts` carries a Responses provider's own `encrypted_content` in an `ocxr1:` `nat` envelope (`src/responses/reasoning-envelope.ts`) only when the serving attempt has committed and `src/server/responses/core-replay.ts` supplied a durable destination and credential owner. The `nat.tag` HMAC binds the exact blob to that owner and the installation's persisted salt. `src/claude/inbound.ts` restores the blob and item id only for the same client-facing model; each route binding verifies the tag against its effective endpoint, including `responsesPath`, and generation-bearing credential, then removes a mismatch from both the raw reasoning item and the parser's thinking-signature carrier before send. Missing owner or tag fails closed, including direct-forward credentials and OAuth generation changes. Native Anthropic ingress projects away every `nat` on both native branches, dropping blob-only blocks and leaving signed or visible-text fields intact. A thinking block that closes before serving commit has no `nat`. Translated bodies that reason request `include: ["reasoning.encrypted_content"]`; a body without reasoning does not. Anthropic `sig` and `red` envelopes keep their existing behavior. `tests/claude-integration/claude-native-reasoning-provenance.test.ts` covers route ownership and native projection. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 5b08085a0d5..28ed67a9c70 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -203,7 +203,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result), which the Claude outbound renders back as the same block pair; a search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing, and the replayed pair is dropped by the inbound translation, so a result without `encrypted_content` never reaches Anthropic. A streamed `web_fetch_tool_result` opens and closes the same cell with `fetch` set (the fetched URL and Anthropic's result content, verbatim); the bridge records it as an `open_page` `web_search_call` carrying `fetch_result`, and the Claude outbound renders it back as `server_tool_use` `web_fetch` + that `web_fetch_tool_result`, not counted in `web_search_requests`. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input loses its query or URL label without losing the result. Citations are not relayed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 35b385f3d0a..005cebd1517 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { webSearchPairFromItem } from "../../src/claude/outbound"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; @@ -168,21 +169,39 @@ describe("server-side search across responses", () => { expect(blockStarts(clientSse).map(b => b.type)).toEqual(["tool_use"]); }); - test("a result whose call came in an earlier response still reaches the client by its tool_use_id", async () => { - const { clientSse } = await claudeTurn("https://api.anthropic.com", [MESSAGE_START, ...SEARCH_TURN.slice(5, 10), ...MESSAGE_END]); - const starts = blockStarts(clientSse); - expect(starts.map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); - expect(starts[1]).toMatchObject({ tool_use_id: starts[0]!.id, content: [{ type: "web_search_result", url: "https://bun.sh/blog" }] }); + test("an orphan result with a unique anchor never synthesizes or replays a server call", async () => { + const mismatched = SEARCH_TURN.map(f => f.type === "content_block_start" && f.index === 1 + ? { ...f, content_block: { ...(f.content_block as Record), tool_use_id: "srvtoolu_orphan" } } : f); + const truncated = SEARCH_TURN.map((f, i) => i === 2 + ? { ...f, delta: { type: "input_json_delta", partial_json: `{"query":"${"x".repeat(4096)}` } } : f); + for (const frames of [ + [MESSAGE_START, ...SEARCH_TURN.slice(5, 10), ...MESSAGE_END], + [MESSAGE_START, ...fetchTurn(FETCH_RESULT).slice(4)], + mismatched, + truncated, + ]) { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", frames)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Search" }, { role: "assistant", content: turn }, { role: "user", content: "More?" }, + ]); + const messages = wire.messages as { content: Record[] }[]; + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain("web_search_tool_result"); + expect(JSON.stringify(messages)).not.toContain("web_fetch_tool_result"); + expect(turn).toEqual([{ type: "text", text: "Bun shipped." }]); + } }); - test("a single input fragment past 4 KiB is not retained beyond the cap", async () => { + test("a single input fragment past 4 KiB drops its unprovable exchange at the cap", async () => { const query = "q".repeat(5000); const { clientSse } = await claudeTurn("https://api.anthropic.com", [ ...SEARCH_TURN.slice(0, 2), { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ query }) } }, ...SEARCH_TURN.slice(4), ]); - expect(blockStarts(clientSse).map(b => b.type)).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + expect(blockStarts(clientSse)).toEqual([{ type: "text", text: "" }]); + expect(clientContent(clientSse)).toEqual([{ type: "text", text: "Bun shipped." }]); expect(clientSse).not.toContain(query); }); @@ -260,3 +279,206 @@ describe("anthropic adapter: typed web tools beside function tools", () => { expect(tools.map(t => t.type ?? "function")).toEqual(["function", "web_fetch_20250910"]); }); }); + +/** The client's content blocks rebuilt from its SSE, as Claude Code stores them for the next turn. */ +function clientContent(clientSse: string): Record[] { + const blocks: Record[] = []; + const inputs: string[] = []; + for (const f of clientFrames(clientSse)) { + const i = f.index as number; + const delta = f.delta as Record | undefined; + if (f.type === "content_block_start") blocks[i] = { ...(f.content_block as Record) }; + else if (delta?.type === "input_json_delta") inputs[i] = (inputs[i] ?? "") + delta.partial_json; + else if (delta?.type === "text_delta") blocks[i]!.text = String(blocks[i]!.text) + delta.text; + } + return blocks.map((b, i) => inputs[i] ? { ...b, input: JSON.parse(inputs[i]!) } : b); +} + +describe("a server-tool exchange the client replays reaches Anthropic in place", () => { + const followUp = (assistant: unknown[], next: unknown = "More?") => [ + { role: "user", content: "What did Bun release?" }, { role: "assistant", content: assistant }, { role: "user", content: next }, + ]; + + test("web_search pair before text: original srvtoolu_ id and encrypted_content, only to api.anthropic.com", async () => { + const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp(turn1)); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content.slice(0, 2)).toEqual([ + { type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: { query: "bun release" } }, + SEARCH_TURN[5]!.content_block as Record, + ]); + expect(content[2]).toMatchObject({ type: "text", text: "Bun shipped." }); + expect(content).toHaveLength(3); + for (const [baseUrl, target] of [["https://compat.example.com", "anth"], ["https://api.anthropic.com", "gpt"]] as const) { + const other = JSON.stringify((await claudeTurn(baseUrl, OK_TURN, target, followUp(turn1))).wire); + expect(other).not.toContain("srvtoolu_1"); + expect(other).toContain("Bun shipped."); + } + }); + + test("web_fetch pair before a client tool_use keeps its place ahead of the tool_use", async () => { + const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", fetchTurn(FETCH_RESULT))).clientSse); + const toolUse = { type: "tool_use", id: "toolu_1", name: "Read", input: {} }; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", + followUp([...turn1.slice(0, 2), toolUse], [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }])); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content).toEqual([ + { type: "server_tool_use", id: "srvtoolu_f", name: "web_fetch", input: { url: "https://bun.sh/blog" } }, + { type: "web_fetch_tool_result", tool_use_id: "srvtoolu_f", content: FETCH_RESULT }, + toolUse, + ]); + }); + + test("a repeated anchor drops its run instead of guessing an owner", async () => { + const [use, result] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse).slice(0, 2); + const thinking = (signature: string) => ({ type: "thinking", thinking: `t ${signature}`, signature }); + const second = [{ ...use, id: "srvtoolu_2" }, { ...result, tool_use_id: "srvtoolu_2" }]; + const third = [{ ...use, id: "srvtoolu_3" }, { ...result, tool_use_id: "srvtoolu_3" }]; + const read = (id: string) => ({ type: "tool_use", id, name: "Read", input: {} }); + // Shape of the maintainer's counterexample on #7236: an earlier string-content turn with the same text, + // then repeated text anchors around identical searches, identical calls and signed thinking. + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "What did Bun release?" }, + { role: "assistant", content: [ + thinking("SigOne0123456789abcdef"), use, result, { type: "text", text: "Bun shipped." }, + ...second, { type: "text", text: "Bun shipped." }, thinking("SigTwo0123456789abcdef"), ...third, read("toolu_a"), read("toolu_b"), + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_a", content: "ok" }, { type: "tool_result", tool_use_id: "toolu_b", content: "ok" }] }, + ]); + const ids = (wire.messages as { content: unknown }[]) + .map(m => Array.isArray(m.content) ? m.content.map(b => { const r = b as Record; return String(r.id ?? r.tool_use_id ?? r.type); }) : []); + expect(ids[1]).toEqual(["text"]); + // Only srvtoolu_3, anchored on the unique call toolu_a, replays; the text-anchored runs are ambiguous. + expect(ids[3]).toEqual(["thinking", "text", "text", "thinking", "srvtoolu_3", "srvtoolu_3", "toolu_a", "toolu_b"]); + }); + + test("a string-form assistant cannot steal a later identical text anchor", async () => { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "Search" }, { role: "assistant", content: turn }, + { role: "user", content: "More?" }, + ]); + const messages = wire.messages as { content: Record[] }[]; + expect(messages[3]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain("web_search_tool_result"); + }); + + test("repeated identical calls drop ambiguous server exchanges and preserve assistant turns", async () => { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const call = { type: "tool_use", id: "toolu_repeat", name: "Read", input: {} }; + const result = [{ type: "tool_result", tool_use_id: "toolu_repeat", content: "ok" }]; + const first = [...turn, call]; + const second = turn.map(b => b.id === "srvtoolu_1" ? { ...b, id: "srvtoolu_2" } + : b.tool_use_id === "srvtoolu_1" ? { ...b, tool_use_id: "srvtoolu_2" } : b); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "Search" }, { role: "assistant", content: first }, + { role: "user", content: result }, { role: "assistant", content: [...second, call] }, + { role: "user", content: result }, + ]); + const messages = wire.messages as { content: Record[] }[]; + expect(messages[3]!.content).toEqual([{ type: "text", text: "Bun shipped." }, call]); + expect(messages[5]!.content).toEqual([{ type: "text", text: "Bun shipped." }, call]); + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + }); + + test("repeated string, thinking and redacted anchors drop exchanges and preserve signed blocks", async () => { + const turn = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const [use, result, text] = turn; + const thinking = { type: "thinking", thinking: "checking", signature: "SigRepeat0123456789abcdef" }; + const redacted = { type: "redacted_thinking", data: "RedactedRepeat0123456789" }; + const pair = (id: string) => [{ ...use, id }, { ...result, tool_use_id: id }]; + const assistant = [thinking, ...pair("srvtoolu_text"), text, redacted, + ...pair("srvtoolu_thinking"), thinking, ...pair("srvtoolu_redacted"), redacted, + ...pair("srvtoolu_text2"), text]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", [ + { role: "user", content: "Hi" }, { role: "assistant", content: "Bun shipped." }, + { role: "user", content: "Search" }, { role: "assistant", content: assistant }, + { role: "user", content: "More?" }, + ]); + const messages = wire.messages as { role: string; content: Record[] }[]; + // Reasoning splits this source turn into adjacent translated assistant messages. + const later = messages.slice(3).filter(m => m.role === "assistant").flatMap(m => m.content); + expect(later).toEqual([thinking, text, redacted, thinking, redacted, text]); + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + }); + + test("a pair between thinking blocks stays between them", async () => { + const [use, result] = (await claudeTurn("https://api.anthropic.com", SEARCH_TURN).then(r => clientContent(r.clientSse))).slice(0, 2); + const thinking = (signature: string) => ({ type: "thinking", thinking: `t ${signature}`, signature }); + const redacted = { type: "redacted_thinking", data: "RedactedPayload0123456789" }; + const second = [{ ...use, id: "srvtoolu_2" }, { ...result, tool_use_id: "srvtoolu_2" }]; + const assistant = [thinking("SigOne0123456789abcdef"), use, result, thinking("SigTwo0123456789abcdef"), ...second, redacted]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([...assistant, { type: "text", text: "Bun shipped." }])); + const content = (wire.messages as { content: Record[] }[])[1]!.content; + expect(content.map(b => b.id ?? b.tool_use_id ?? b.type)).toEqual([ + "thinking", "srvtoolu_1", "srvtoolu_1", "thinking", "srvtoolu_2", "srvtoolu_2", "redacted_thinking", "text", + ]); + }); + + test("duplicate or unmatched server-tool ids never replay an orphan result", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const orphan = { ...result, tool_use_id: "srvtoolu_orphan" }; + for (const blocks of [[use, orphan], [use, use, result, orphan], [use, use, result, result]]) { + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([...blocks, text])); + expect((wire.messages as { content: unknown }[])[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + } + }); + + test("malformed server-tool input drops the exchange without changing its unique text anchor", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + for (const [name, field, resultType] of [["web_search", "query", "web_search_tool_result"], ["web_fetch", "url", "web_fetch_tool_result"]] as const) { + for (const input of [undefined, null, [], "invalid", {}, { [field]: 42 }, { [field]: "" }, { [field]: " " }]) { + const call = { ...use, name, input }; + const matched = { ...result, type: resultType, ...(name === "web_fetch" ? { content: FETCH_RESULT } : {}) }; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([call, matched, text])); + const messages = wire.messages as { content: unknown }[]; + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain(resultType); + } + } + }); + + test("a result before its call drops the exchange instead of replaying the reversed pair", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp([result, use, text])); + const messages = wire.messages as { content: unknown }[]; + expect(messages[1]!.content).toEqual([{ type: "text", text: "Bun shipped." }]); + expect(JSON.stringify(messages)).not.toContain("server_tool_use"); + expect(JSON.stringify(messages)).not.toContain("web_search_tool_result"); + }); + + test("overlapping calls replay in source order when each result follows its own call", async () => { + const [use, result, text] = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + const secondUse = { ...use, id: "srvtoolu_second" }; + const secondResult = { ...result, tool_use_id: "srvtoolu_second" }; + const turn = [use, secondUse, secondResult, result, text]; + const { wire } = await claudeTurn("https://api.anthropic.com", OK_TURN, "anth", followUp(turn)); + expect((wire.messages as { content: unknown }[])[1]!.content).toEqual(turn); + }); +}); + +test("a malformed anthropic_blocks falls back to the rebuilt pair", () => { + const use = { type: "server_tool_use", id: "srvtoolu_stored", name: "web_search", input: { query: "stored" } }; + for (const anthropic_blocks of [{}, { use: {}, result: {} }, { use: { id: 1, name: "web_search" }, result: { type: "web_search_tool_result" } }, + { use, result: { type: "web_search_tool_result", tool_use_id: "srvtoolu_other" } }, + { use, result: { type: "web_fetch_tool_result", tool_use_id: use.id } }, + { use: { ...use, input: undefined }, result: { type: "web_search_tool_result", tool_use_id: use.id } }, + ]) { + const pair = webSearchPairFromItem({ type: "web_search_call", id: "ws_1", action: { type: "search", query: "q" }, anthropic_blocks }); + expect(pair).toMatchObject({ id: "ws_1", name: "web_search", resultType: "web_search_tool_result", input: { query: "q" } }); + } +}); + +test("an item emitted before anthropic_blocks still maps its fetch_result to a web_fetch pair", () => { + const pair = webSearchPairFromItem({ + type: "web_search_call", id: "srvtoolu_old", action: { type: "open_page", url: "https://bun.sh/blog" }, + fetch_result: FETCH_RESULT, + }); + expect(pair).toMatchObject({ id: "srvtoolu_old", name: "web_fetch", resultType: "web_fetch_tool_result", input: { url: "https://bun.sh/blog" }, resultContent: FETCH_RESULT }); +});