diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5b688af5fbf..9345b6a3866 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -818,6 +818,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | Assistant text | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringified `arguments`) | | 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"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 378e672bd99..b545d4ca0e0 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -524,6 +524,7 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | Assistant 텍스트 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 문자열로 변환한 `arguments`) | | 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) | +| 도구 결과의 `tool_reference` | 짝지어진 결과에 `Tool loaded: ` 텍스트로 보존합니다. 도구 선언을 추가하거나 번역 경로의 서버 측 지연 로딩을 활성화하지는 않습니다. | | `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 | | Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f331f7c3c5c..63fa49d686a 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -388,7 +388,7 @@ "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", - "claude-inbound.test.ts": "claude-integration", + "claude-inbound.test.ts": "claude-integration", "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 1b190193362..243473beae6 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -72,6 +72,15 @@ function toolResultOutput(block: Rec): string | Rec[] { if (!isRec(item)) continue; if (item.type === "text" && typeof item.text === "string") { out.push({ type: "input_text", text: item.text }); + } else if (item.type === "tool_reference" && typeof item.tool_name === "string" && item.tool_name.length > 0) { + // Responses has no tool_reference result carrier. Preserve ToolSearch's loaded-name + // evidence as result text so a reference-only reply does not become empty (#6647). + // This describes client output; it must not create tools or widen authorization. + // Text-only tool output is later joined without separators, so the marker carries its + // own line breaks to keep each loaded name distinct from its neighbours. + const prev = out[out.length - 1]; + const lead = prev?.type === "input_text" && typeof prev.text === "string" && !prev.text.endsWith("\n") ? "\n" : ""; + out.push({ type: "input_text", text: `${lead}Tool loaded: ${item.tool_name}\n` }); } else if (item.type === "image") { const img = imageBlockToInputImage(item); if (img) out.push(img); diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 39d79a32da0..bab6350ed3d 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -518,6 +518,8 @@ The [compaction routing override](../transports/responses-failover.md#compaction ## Routed bundled-skill text +Translated tool results in `src/claude/inbound.ts` retain nonempty string `tool_reference.tool_name` values as `Tool loaded: ` text lines in the paired `function_call_output`, preserving mixed-content order and the error marker. Each marker ends with a newline and starts with one after text that does not, because text-only tool output is joined without separators downstream. References describe client output; they do not declare or authorize executable tools, enable translated server-side deferral, or alter native passthrough. `tests/claude-integration/claude-inbound-tool-reference.test.ts` covers reference-only results, mixed/error output, malformed names, caller immutability and the real Responses parser. + `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. diff --git a/tests/claude-integration/claude-inbound-tool-reference.test.ts b/tests/claude-integration/claude-inbound-tool-reference.test.ts new file mode 100644 index 00000000000..f01427ab741 --- /dev/null +++ b/tests/claude-integration/claude-inbound-tool-reference.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { parseRequest } from "../../src/responses/parser"; +import { responsesRequestSchema } from "../../src/responses/schema"; + +function request(content: unknown[], isError = false) { + return { + model: "routed-model", max_tokens: 256, + tools: [{ name: "ToolSearch", input_schema: { type: "object", properties: {} } }], + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "search_1", name: "ToolSearch", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "search_1", is_error: isError, content }] }, + { role: "user", content: "Continue the original task." }, + ], + }; +} + +describe("translated Claude tool references", () => { + test("reference-only ToolSearch results survive schema parsing and retain the call slot", () => { + const raw = request([ + { type: "tool_reference", tool_name: "WebFetch" }, + { type: "tool_reference", tool_name: "mcp__docs__lookup" }, + ]); + const before = JSON.stringify(raw); + const body = anthropicToResponsesBody(raw); + expect(() => responsesRequestSchema.parse(body)).not.toThrow(); + expect(() => parseRequest(body)).not.toThrow(); + expect(body.input).toEqual([ + { type: "function_call", call_id: "search_1", name: "ToolSearch", arguments: "{}" }, + { type: "function_call_output", call_id: "search_1", output: [ + { type: "input_text", text: "Tool loaded: WebFetch\n" }, + { type: "input_text", text: "Tool loaded: mcp__docs__lookup\n" }, + ] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "Continue the original task." }] }, + ]); + expect(JSON.stringify(raw)).toBe(before); + // A result describes client-loaded tools; it must not add executable declarations. + expect(body.tools).toHaveLength(1); + }); + + test("mixed output preserves order and errors without mutating frozen references", () => { + const reference = Object.freeze({ type: "tool_reference", tool_name: "Read" }); + const body = anthropicToResponsesBody(request([ + { type: "text", text: "Before" }, reference, + { type: "image", source: { type: "url", url: "https://example.invalid/fixture.png" } }, + { type: "text", text: "After" }, + ], true)); + expect(body.input[1]).toEqual({ type: "function_call_output", call_id: "search_1", output: [ + { type: "input_text", text: "[tool error]" }, + { type: "input_text", text: "Before" }, + { type: "input_text", text: "\nTool loaded: Read\n" }, + { type: "input_image", image_url: "https://example.invalid/fixture.png" }, + { type: "input_text", text: "After" }, + ] }); + expect(reference).toEqual({ type: "tool_reference", tool_name: "Read" }); + }); + + test("loaded names stay line-separated after the parser joins text-only output", () => { + const parsed = parseRequest(anthropicToResponsesBody(request([ + { type: "text", text: "Before" }, + { type: "tool_reference", tool_name: "Read" }, + { type: "tool_reference", tool_name: "Write" }, + { type: "text", text: "After" }, + ]))); + const result = parsed.context.messages.find((message) => message.role === "toolResult"); + expect(result?.content).toBe("Before\nTool loaded: Read\nTool loaded: Write\nAfter"); + const onlyRefs = parseRequest(anthropicToResponsesBody(request([ + { type: "tool_reference", tool_name: "WebFetch" }, + { type: "tool_reference", tool_name: "mcp__docs__lookup" }, + ]))); + expect(onlyRefs.context.messages.find((message) => message.role === "toolResult")?.content) + .toBe("Tool loaded: WebFetch\nTool loaded: mcp__docs__lookup\n"); + }); + + test("missing, non-string and empty names do not fabricate loaded tools", () => { + const body = anthropicToResponsesBody(request([ + { type: "tool_reference" }, { type: "tool_reference", tool_name: 42 }, + { type: "tool_reference", tool_name: "" }, { type: "text", text: "Still present" }, + ])); + expect(body.input[1]).toEqual({ type: "function_call_output", call_id: "search_1", output: [ + { type: "input_text", text: "Still present" }, + ] }); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71e304011d0..03d10bf0d4c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -261,6 +261,7 @@ "claude-first-party-union.test.ts": "claude-integration", "claude-desktop-system-proxy.test.ts": "claude-integration", "claude-gateway-cache.test.ts": "claude-integration", "claude-inbound-cache-stabilize.test.ts": "claude-integration", "claude-inbound-debug.test.ts": "claude-integration", "claude-inbound.test.ts": "claude-integration", + "claude-inbound-tool-reference.test.ts": "claude-integration", "claude-output-schema-strict.test.ts": "claude-integration", "claude-intercept-integration.test.ts": "server", "claude-intercept-local-ca.test.ts": "claude-integration", "claude-intercept-local-ca-files.test.ts": "claude-integration",