From c2ebd5f2f5641a7970b7a7615279802235093bc8 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 00:45:36 +0800 Subject: [PATCH 1/6] fix(claude): replay Anthropic server-tool exchanges on the translated lane The Claude client never held a replayable copy of an Anthropic server-side search or fetch: the relayed server_tool_use carried the bridge's ws_ id, search hits lost encrypted_content and page_age, and the inbound translation dropped the replayed blocks, so the next api.anthropic.com turn lost them. - adapter attaches the raw server_tool_use + result block to web_search_call_end; bridge, direct encoder and Claude outbound relay them verbatim as anthropic_blocks (replaces fetch_result) - replayedAnthropicServerBlocks collects complete srvtoolu_ pairs from the raw request, keyed by the following tool_use id or text, and carries them out of body like anthropicServerTools - messagesToAnthropicFormat re-inserts them in place for api.anthropic.com only; GPT legs and third-party endpoints are unchanged Co-Authored-By: Claude Code --- .../src/content/docs/guides/claude-code.md | 2 +- src/adapters/anthropic.ts | 60 ++++++++++--------- src/bridge/internal.ts | 8 ++- src/bridge/response-json.ts | 4 +- src/bridge/sse.ts | 13 ++-- src/claude/inbound-content-options.ts | 33 ++++++++++ src/claude/outbound.ts | 17 +++--- src/protocols/encoders/adapter-events.ts | 10 ++-- src/protocols/encoders/messages.ts | 4 +- src/server/claude-messages.ts | 3 +- src/server/responses/core-options.ts | 2 + src/server/responses/request-prepare.ts | 1 + src/server/responses/response-effects.ts | 1 + src/types.ts | 1 + src/types/request.ts | 12 +++- structure/clients/claude-desktop.md | 2 +- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 50 ++++++++++++++++ 18 files changed, 165 insertions(+), 60 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 159886a85e0..f91ba17fffa 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. 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`, verbatim. When the client sends that pair back in its history, the next `api.anthropic.com` request carries it in place; other destinations never see it | | `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..21031d7d3d1 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 streamed input (web_search `{query}`, web_fetch `{url}`); {} when it did not parse. */ +function serverToolInput(json: string): Record { 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 : {}; } catch { - return ""; + return {}; } } @@ -735,7 +735,17 @@ 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. Each key is used + // once. ponytail: an identical text reused across turns takes the run at its first occurrence; + // key on message index + text if that ever misplaces one. + const pendingReplay = new Map(serverReplay); + const replayed = (key: string) => { + const blocks = pendingReplay.get(key) ?? []; + pendingReplay.delete(key); + 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,6 +799,7 @@ 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; @@ -806,6 +817,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 +1010,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 +1350,24 @@ 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. A result whose call came in an earlier response has no streamed input here. + const fetch = block.type === "web_fetch_tool_result"; + const input = serverSearch?.id === block.tool_use_id ? serverToolInput(serverSearch.json) : {}; 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; 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..1b0552f50ce 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -87,6 +87,39 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { return tools.length > 0 ? tools : undefined; } +/** + * 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:` or `text:`, both of which survive translation. A run + * with neither, 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(); + for (const message of raw.messages) { + if (!isRec(message) || message.role !== "assistant" || !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 key = block.type === "tool_use" ? `call:${String(block.id)}` : block.type === "text" ? `text:${String(block.text)}` : undefined; + const uses = run.filter(b => b.type === "server_tool_use"); + const results = run.filter(b => b.type !== "server_tool_use"); + const replayable = uses.length > 0 && uses.length === results.length + && uses.every(u => typeof u.id === "string" && u.id.startsWith("srvtoolu_") && results.some(r => r.tool_use_id === u.id)) + && results.every(r => !Array.isArray(r.content) || r.content.every(h => isRec(h) && typeof h.encrypted_content === "string")); + if (key && replayable && !out.has(key)) out.set(key, run); + run = []; + } + } + 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..178d5d9c1d0 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -223,20 +223,21 @@ 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"; - 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 }; + if (isRec(item.anthropic_blocks)) { + const { use, result } = item.anthropic_blocks as { use: Rec; result: Rec }; + return { id: String(use.id), name: String(use.name), resultType: String(result.type), input: use.input as Rec, resultContent: result.content, completed }; } + const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; const queries = Array.isArray(action.queries) ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0) : []; 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..ed8328f2316 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 `tool_use` id or text that follows it and 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..93c07c7d634 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 streamed input and the result block, verbatim. 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` or text that followed them, for `api.anthropic.com` only; a pair without `encrypted_content`, or with neither a `tool_use` nor text after it, stays dropped. 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, 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..9df61403d5f 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -260,3 +260,53 @@ 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, + ]); + }); +}); From 73c32d6c61ef46e2557b3c8e3dddfdaec3206c3b Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 01:36:21 +0800 Subject: [PATCH 2/6] fix(claude): keep replayed server blocks across thinking; guard anthropic_blocks - A server-tool run followed by a thinking or redacted_thinking block was dropped. Key it by the thinking signature / redacted data, which survive translation, so the adapter re-inserts it ahead of that block and the original order holds. - webSearchPairFromItem trusted anthropic_blocks' shape; a malformed value threw (missing use) or produced an "undefined" id. Fall back to the rebuilt pair unless use/result carry string id/name/type. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 3 +++ src/claude/inbound-content-options.ts | 11 ++++++---- src/claude/outbound.ts | 6 +++--- structure/clients/claude-desktop.md | 2 +- structure/providers-and-adapters.md | 2 +- .../claude-translated-server-tools.test.ts | 21 +++++++++++++++++++ 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 21031d7d3d1..f99829b022f 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -803,6 +803,9 @@ function messagesToAnthropicFormat( 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 ?? []) preface.push(...replayed(`redacted:${data}`)); + preface.push(...replayed(`thinking:${t.signature}`)); // 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; diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 1b0552f50ce..98e5d514f5b 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -91,9 +91,9 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { * 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:` or `text:`, both of which survive translation. A run - * with neither, an unpaired call, a non-Anthropic id, or a search hit without `encrypted_content` - * is not replayable and stays dropped. + * in its message: `call:`, `text:`, `thinking:` or + * `redacted:`, all of which survive translation. 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; @@ -107,7 +107,10 @@ export function replayedAnthropicServerBlocks(raw: unknown): Map run.push({ ...block, cache_control: undefined }); continue; } - const key = block.type === "tool_use" ? `call:${String(block.id)}` : block.type === "text" ? `text:${String(block.text)}` : undefined; + const key = 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; const uses = run.filter(b => b.type === "server_tool_use"); const results = run.filter(b => b.type !== "server_tool_use"); const replayable = uses.length > 0 && uses.length === results.length diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 178d5d9c1d0..a6af40041c3 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -233,9 +233,9 @@ export function webSearchPairFromItem(item: Rec): { } { const action = isRec(item.action) ? item.action : {}; const completed = item.status !== "failed"; - if (isRec(item.anthropic_blocks)) { - const { use, result } = item.anthropic_blocks as { use: Rec; result: Rec }; - return { id: String(use.id), name: String(use.name), resultType: String(result.type), input: use.input as Rec, resultContent: result.content, completed }; + const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {}; + if (isRec(use) && isRec(result) && typeof use.id === "string" && typeof use.name === "string" && typeof result.type === "string") { + return { id: use.id, name: use.name, resultType: result.type, input: isRec(use.input) ? use.input : {}, resultContent: result.content, completed }; } const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`; const queries = Array.isArray(action.queries) diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index ed8328f2316..3ec4ad4e750 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. `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 `tool_use` id or text that follows it and 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/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) and 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 93c07c7d634..5bc48f32a96 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` 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 streamed input and the result block, verbatim. 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` or text that followed them, for `api.anthropic.com` only; a pair without `encrypted_content`, or with neither a `tool_use` nor text after it, stays dropped. 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, 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.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 streamed input and the result block, verbatim. 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; a pair without `encrypted_content`, or with no such block after it, stays dropped. A malformed `anthropic_blocks` falls back to the rebuilt pair. 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, 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 9df61403d5f..671c37487f7 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"; @@ -309,4 +310,24 @@ describe("a server-tool exchange the client replays reaches Anthropic in place", toolUse, ]); }); + + 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("a malformed anthropic_blocks falls back to the rebuilt pair", () => { + for (const anthropic_blocks of [{}, { use: {}, result: {} }, { use: { id: 1, name: "web_search" }, result: { type: "web_search_tool_result" } }]) { + 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" } }); + } }); From 4f73735e08041af57e517d4ff381c1259beef7c5 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 04:34:05 +0800 Subject: [PATCH 3/6] fix(claude): keep replayed server-tool pairs in the signed source order #7143 replays signed assistant turns from originalOrder, so replayed pairs must go through appendPreface like every other preface block. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index f99829b022f..9aab23dd446 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -804,8 +804,8 @@ function messagesToAnthropicFormat( } 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 ?? []) preface.push(...replayed(`redacted:${data}`)); - preface.push(...replayed(`thinking:${t.signature}`)); + 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; From 044df47bc9e0c2c6c2763350319e8d1c89bf0379 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 11:12:52 +0800 Subject: [PATCH 4/6] fix(claude): key replayed server-tool runs by anchor occurrence A replayed run was keyed on its anchor text/id across the whole conversation and consumed at the first match, so an identical earlier assistant text took a later turn's exchange. Both sides now count each anchor's occurrences and match on #. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 15 +++++++-------- src/claude/inbound-content-options.ts | 11 ++++++++--- structure/clients/claude-desktop.md | 2 +- .../claude-translated-server-tools.test.ts | 12 ++++++++++++ 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 9aab23dd446..2f63f0a0573 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -737,14 +737,13 @@ function messagesToAnthropicFormat( 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. Each key is used - // once. ponytail: an identical text reused across turns takes the run at its first occurrence; - // key on message index + text if that ever misplaces one. - const pendingReplay = new Map(serverReplay); - const replayed = (key: string) => { - const blocks = pendingReplay.get(key) ?? []; - pendingReplay.delete(key); - return blocks; + // The client's server-tool exchanges go back ahead of the block they preceded, matched by that + // anchor's occurrence count so an identical earlier text cannot take a later turn's run. + const seenAnchors = new Map(); + const replayed = (anchor: string) => { + const n = seenAnchors.get(anchor) ?? 0; + seenAnchors.set(anchor, n + 1); + return serverReplay?.get(`${anchor}#${n}`) ?? []; }; // 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 diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index 98e5d514f5b..f5b4cdb89e4 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -92,12 +92,14 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { * `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. A run with none, an unpaired call, a + * `redacted:`, all of which survive translation, suffixed `#` for that anchor's n-th + * occurrence in the conversation so a repeated text keeps its own run. 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(); for (const message of raw.messages) { if (!isRec(message) || message.role !== "assistant" || !Array.isArray(message.content)) continue; let run: Rec[] = []; @@ -107,16 +109,19 @@ export function replayedAnthropicServerBlocks(raw: unknown): Map run.push({ ...block, cache_control: undefined }); continue; } - const key = block.type === "tool_use" ? `call:${String(block.id)}` + 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; + const n = anchor ? seen.get(anchor) ?? 0 : 0; + if (anchor) seen.set(anchor, n + 1); + const key = anchor && `${anchor}#${n}`; const uses = run.filter(b => b.type === "server_tool_use"); const results = run.filter(b => b.type !== "server_tool_use"); const replayable = uses.length > 0 && uses.length === results.length && uses.every(u => typeof u.id === "string" && u.id.startsWith("srvtoolu_") && results.some(r => r.tool_use_id === u.id)) && results.every(r => !Array.isArray(r.content) || r.content.every(h => isRec(h) && typeof h.encrypted_content === "string")); - if (key && replayable && !out.has(key)) out.set(key, run); + if (key && replayable) out.set(key, run); run = []; } } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 3ec4ad4e750..2243b71ad40 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. `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) and 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/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) plus its occurrence index, `#`, so a repeated text keeps its own run, and 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/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 671c37487f7..b8a22185f82 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -311,6 +311,18 @@ describe("a server-tool exchange the client replays reaches Anthropic in place", ]); }); + test("an identical earlier text does not take a later turn's exchange", async () => { + const turn1 = 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: [{ type: "text", text: "Bun shipped." }] }, + { role: "user", content: "What did Bun release?" }, { role: "assistant", content: turn1 }, { role: "user", content: "More?" }, + ]); + const types = (wire.messages as { content: unknown }[]) + .map(m => Array.isArray(m.content) ? m.content.map(b => String((b as Record).type)) : []); + expect(types[1]).toEqual(["text"]); + expect(types[3]).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + }); + 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 }); From 1d0b0a4e8900f3a38d550b0f88f3c4a86323cf1b Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 15:44:48 +0800 Subject: [PATCH 5/6] fix(claude): keep mapping a pre-anthropic_blocks fetch_result item to web_fetch An earlier Responses item from dev carries a server web_fetch as fetch_result; without this branch a translated continuation reusing it turned the fetch into an empty-query web_search pair. Co-Authored-By: Claude Code --- src/claude/outbound.ts | 5 +++++ .../claude-translated-server-tools.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index a6af40041c3..dda7044f108 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -238,6 +238,11 @@ export function webSearchPairFromItem(item: Rec): { return { id: use.id, name: use.name, resultType: result.type, input: isRec(use.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 }; + } const queries = Array.isArray(action.queries) ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0) : []; diff --git a/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index b8a22185f82..51afe87c1a7 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -343,3 +343,11 @@ test("a malformed anthropic_blocks falls back to the rebuilt pair", () => { 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 }); +}); From 22afc4e5a58e11ecf1d4c3e84a4eef5f84d7bc4a Mon Sep 17 00:00:00 2001 From: Vadym O Date: Fri, 16 Oct 2026 16:48:41 +0800 Subject: [PATCH 6/6] fix(claude): replay a server-tool run only when its anchor is unique Occurrence indexes still misassigned runs when an earlier assistant turn used string content: capture skipped it, translation kept it, and replay shifted every later run of that text by one. Capture now counts every assistant anchor, string content included, and drops any run whose anchor repeats; the adapter uses each remaining unique anchor once. A repeated anchor degrades to the pre-replay behaviour (dropped), never a wrong owner. Co-Authored-By: Claude Code --- src/adapters/anthropic.ts | 12 ++++---- src/claude/inbound-content-options.ts | 17 ++++++----- structure/clients/claude-desktop.md | 2 +- .../claude-translated-server-tools.test.ts | 28 +++++++++++++------ 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 2f63f0a0573..37beb84b6c6 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -737,13 +737,13 @@ function messagesToAnthropicFormat( 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, matched by that - // anchor's occurrence count so an identical earlier text cannot take a later turn's run. - const seenAnchors = new Map(); + // 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 n = seenAnchors.get(anchor) ?? 0; - seenAnchors.set(anchor, n + 1); - return serverReplay?.get(`${anchor}#${n}`) ?? []; + 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 diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index f5b4cdb89e4..de6087e2450 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -92,16 +92,20 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined { * `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, suffixed `#` for that anchor's n-th - * occurrence in the conversation so a repeated text keeps its own run. A run with none, an unpaired call, a + * `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" || !Array.isArray(message.content)) continue; + 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") { @@ -113,18 +117,17 @@ export function replayedAnthropicServerBlocks(raw: unknown): Map : block.type === "text" ? `text:${String(block.text)}` : block.type === "thinking" ? `thinking:${String(block.signature)}` : block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined; - const n = anchor ? seen.get(anchor) ?? 0 : 0; - if (anchor) seen.set(anchor, n + 1); - const key = anchor && `${anchor}#${n}`; + if (anchor) count(anchor); const uses = run.filter(b => b.type === "server_tool_use"); const results = run.filter(b => b.type !== "server_tool_use"); const replayable = uses.length > 0 && uses.length === results.length && uses.every(u => typeof u.id === "string" && u.id.startsWith("srvtoolu_") && results.some(r => r.tool_use_id === u.id)) && results.every(r => !Array.isArray(r.content) || r.content.every(h => isRec(h) && typeof h.encrypted_content === "string")); - if (key && replayable) out.set(key, run); + if (anchor && replayable) 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; } diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 2243b71ad40..f2a3a795989 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. `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) plus its occurrence index, `#`, so a repeated text keeps its own run, and 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/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. 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/tests/claude-integration/claude-translated-server-tools.test.ts b/tests/claude-integration/claude-translated-server-tools.test.ts index 51afe87c1a7..784367d3345 100644 --- a/tests/claude-integration/claude-translated-server-tools.test.ts +++ b/tests/claude-integration/claude-translated-server-tools.test.ts @@ -311,16 +311,28 @@ describe("a server-tool exchange the client replays reaches Anthropic in place", ]); }); - test("an identical earlier text does not take a later turn's exchange", async () => { - const turn1 = clientContent((await claudeTurn("https://api.anthropic.com", SEARCH_TURN)).clientSse); + 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: [{ type: "text", text: "Bun shipped." }] }, - { role: "user", content: "What did Bun release?" }, { role: "assistant", content: turn1 }, { role: "user", content: "More?" }, + { 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 types = (wire.messages as { content: unknown }[]) - .map(m => Array.isArray(m.content) ? m.content.map(b => String((b as Record).type)) : []); - expect(types[1]).toEqual(["text"]); - expect(types[3]).toEqual(["server_tool_use", "web_search_tool_result", "text"]); + 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 pair between thinking blocks stays between them", async () => {