From f4f06f290c054f55c3cdb598e8ef5d58f5b8043d Mon Sep 17 00:00:00 2001 From: Manan Santoki Date: Thu, 17 Sep 2026 18:11:20 +0700 Subject: [PATCH] fix(translator): keep tool-result images, restore Kiro tool names, preserve thinking display Forward images inside tool_result to OpenAI and Kiro upstreams via following user messages, restore original client tool names on Kiro responses via _toolNameMap, and preserve thinking display settings across translations. --- open-sse/executors/base.js | 2 +- open-sse/executors/default.js | 4 +- open-sse/providers/shared.js | 13 +- .../translator/concerns/thinkingUnified.js | 11 +- open-sse/translator/formats/claude.js | 30 +++ open-sse/translator/request/claude-to-kiro.js | 19 +- .../translator/request/claude-to-openai.js | 28 ++- .../request/openai-to-commandcode.js | 6 +- open-sse/translator/request/openai-to-kiro.js | 7 + .../translator/response/kiro-to-claude.js | 14 +- .../translator/response/kiro-to-openai.js | 31 ++- tests/translator/agent-client-fixes.test.js | 186 ++++++++++++++++++ 12 files changed, 328 insertions(+), 23 deletions(-) create mode 100644 tests/translator/agent-client-fixes.test.js diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index a4c017e4..18a62229 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -127,7 +127,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream, url, model); + const headers = this.buildHeaders(credentials, stream, url, model, transformedBody); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 00f3e3de..64ad46d0 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -146,7 +146,7 @@ export class DefaultExecutor extends BaseExecutor { return BEARER; } - buildHeaders(credentials, stream = true, url, model) { + buildHeaders(credentials, stream = true, url, model, body = null) { const rt = credentials?.runtimeTransport; const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) }; const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor(); @@ -166,7 +166,7 @@ export class DefaultExecutor extends BaseExecutor { const isClaudeModel = typeof model === "string" && /^claude-/.test(model); if (model && (this.provider === "claude" || (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) { - headers["Anthropic-Beta"] = selectAnthropicBeta(model); + headers["Anthropic-Beta"] = selectAnthropicBeta(model, body); } // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 88488499..c0699c6f 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -62,8 +62,17 @@ const ANTHROPIC_BETA_BASE = [ const ANTHROPIC_BETA_HEAVY_AGENT = ["advanced-tool-use-2025-11-20", "effort-2025-11-24"]; // Heavy-agent beta flags are gated to opus/sonnet — cheaper models don't need them. -export function selectAnthropicBeta(model = "") { - const flags = [...ANTHROPIC_BETA_BASE]; +// `redact-thinking` asks Anthropic to return signature-only thinking blocks, which +// is right for clients that never render thinking but blanks the summaries a +// client explicitly requested with `thinking.display: "summarized"`. +const ANTHROPIC_BETA_REDACT_THINKING = "redact-thinking-2026-02-12"; + +export function wantsThinkingSummaries(body) { + return body?.thinking?.display === "summarized"; +} + +export function selectAnthropicBeta(model = "", body = null) { + const flags = ANTHROPIC_BETA_BASE.filter((flag) => flag !== ANTHROPIC_BETA_REDACT_THINKING || !wantsThinkingSummaries(body)); if (/^claude-(opus|sonnet)/.test(model)) flags.push(...ANTHROPIC_BETA_HEAVY_AGENT); return flags.join(","); } diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 001259e3..4bc9601f 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -232,7 +232,7 @@ function stripAll(body) { } // Apply unified thinking config to body in the resolved provider-native format. -function applyFormat(fmt, body, cfg, caps, supportedLevels) { +function applyFormat(fmt, body, cfg, caps, supportedLevels, display) { const none = cfg.mode === "none"; const canDisable = caps.thinkingCanDisable !== false; // Model cannot disable thinking → clamp "none" to minimal effort instead. @@ -249,7 +249,7 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } // Models that can disable thinking need the explicit adaptive switch. // Permanently adaptive models such as Fable 5.1 accept effort directly. - if (canDisable) body.thinking = { type: "adaptive" }; + if (canDisable) body.thinking = { type: "adaptive", ...(display ? { display } : {}) }; else delete body.thinking; const level = toLevel(eff); body.output_config = { effort: level === "xhigh" || level === "auto" ? "high" : level }; @@ -258,7 +258,7 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels) { case "claude-budget": { if (none && canDisable) { body.thinking = { type: "disabled" }; break; } const budget = toBudget(eff, caps.thinkingRange); - body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 }; + body.thinking = budget === -1 ? { type: "enabled", ...(display ? { display } : {}) } : { type: "enabled", budget_tokens: budget || 8192, ...(display ? { display } : {}) }; break; } case "gemini-level": { @@ -378,7 +378,10 @@ export function applyThinking(targetFormat, model, body, provider = null, intent const fmt = resolveFormat(targetFormat, cleanModel, provider); const supportedLevels = getThinkingLevels(provider, cleanModel); + // Anthropic's `display` (summarized | omitted) decides whether thinking text + // comes back at all; keep what the client asked for instead of resetting it. + const display = typeof body.thinking?.display === "string" ? body.thinking.display : undefined; stripAll(body); - applyFormat(fmt, body, cfg, caps, supportedLevels); + applyFormat(fmt, body, cfg, caps, supportedLevels, display); return body; } diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index f57972da..14c9fc10 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -415,6 +415,28 @@ export function anchorClaudeCache(body) { // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering // - Apply cloaking (billing header + fake user ID) for OAuth tokens +export function hoistToolResultImages(body) { + if (!Array.isArray(body?.messages)) return body; + let touched = false; + const messages = body.messages.map((msg) => { + if (msg?.role !== ROLE.USER || !Array.isArray(msg.content)) return msg; + const hoisted = []; + const content = msg.content.map((block) => { + if (block?.type !== CLAUDE_BLOCK.TOOL_RESULT || !Array.isArray(block.content)) return block; + const images = block.content.filter((c) => c?.type === CLAUDE_BLOCK.IMAGE); + if (!images.length) return block; + const rest = block.content.filter((c) => c?.type !== CLAUDE_BLOCK.IMAGE); + hoisted.push({ type: CLAUDE_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` }, ...images); + return { ...block, content: rest.length ? rest : [{ type: CLAUDE_BLOCK.TEXT, text: "(image attached below)" }] }; + }); + if (!hoisted.length) return msg; + touched = true; + // tool_result blocks must lead a user message; the hoisted image follows them. + return { ...msg, content: [...content, ...hoisted] }; + }); + return touched ? { ...body, messages } : body; +} + export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null, rawHeaders = null, sessionId = null) { // quirk: MiniMax's Claude-compatible endpoint rejects Anthropic's output_config (400 invalid params) if (PROVIDERS[provider]?.quirks?.dropOutputConfig) { @@ -608,6 +630,14 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne } } + // Anthropic itself reads images inside tool_result; other Anthropic-compatible + // endpoints (OpenCode Go, Kimi, DeepSeek, GLM, MiniMax) accept image blocks + // only as user content and silently drop them inside a tool result. Move a + // tool's screenshot out of the result and into the same user turn. + if (provider !== "claude" && !provider?.startsWith("anthropic-compatible")) { + body = hoistToolResultImages(body); + } + // Apply cloaking for OAuth tokens (billing header + fake user ID) // session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) { diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index ef2dd6c5..ac7a704d 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -97,11 +97,21 @@ function convertClaudeMessagesToKiro(messages, model) { if (typeof block.content === "string") { resultContent = block.content; } else if (Array.isArray(block.content)) { + // Images a tool returned (screenshots) ride along as user images; + // Kiro tool results are text-only. + let hasImage = false; + for (const c of block.content) { + if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") { + hasImage = true; + const imageType = c.source.media_type || DEFAULT_IMAGE_MIME; + pendingImages.push({ format: imageType.split("/")[1] || imageType, source: { bytes: c.source.data } }); + } + } resultContent = block.content .filter((c) => c.type === CLAUDE_BLOCK.TEXT) .map((c) => c.text) - .join("\n") || JSON.stringify(block.content); + .join("\n") || (hasImage ? "(image attached)" : JSON.stringify(block.content)); } else if (block.content) { resultContent = JSON.stringify(block.content); } @@ -341,6 +351,13 @@ export function claudeToKiroRequest(model, body, stream, credentials) { enumerable: false, }); + // Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the + // reverse map so tool calls stream back under the client's own names. + const restoredToolNames = new Map(); + for (const [original, sanitized] of nameMap) { + if (original !== sanitized) restoredToolNames.set(sanitized, original); + } + if (restoredToolNames.size) payload._toolNameMap = restoredToolNames; return payload; } diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index 38976226..e75d89b8 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -196,25 +196,41 @@ function convertClaudeMessage(msg) { }); break; - case CLAUDE_BLOCK.TOOL_RESULT: + case CLAUDE_BLOCK.TOOL_RESULT: { let resultContent = ""; + const resultImages = []; if (typeof block.content === "string") { resultContent = block.content; } else if (Array.isArray(block.content)) { - resultContent = block.content - .filter(c => c.type === CLAUDE_BLOCK.TEXT) - .map(c => c.text) - .join("\n") || JSON.stringify(block.content); + for (const c of block.content) { + if (c?.type === CLAUDE_BLOCK.IMAGE && c.source?.type === "base64") { + resultImages.push({ + type: OPENAI_BLOCK.IMAGE_URL, + image_url: { url: encodeDataUri(c.source.media_type, c.source.data) } + }); + } + } + const textOnly = block.content.filter(c => c?.type === CLAUDE_BLOCK.TEXT); + resultContent = textOnly.map(c => c.text).join("\n") + || (resultImages.length ? "" : JSON.stringify(block.content)); } else if (block.content) { resultContent = JSON.stringify(block.content); } - + toolResults.push({ role: ROLE.TOOL, tool_call_id: block.tool_use_id, content: resultContent }); + // The OpenAI tool role is text-only, so a screenshot or any other image a + // tool returned would otherwise vanish. Hand it to the model in the user + // turn that follows the tool messages, tagged with the call it came from. + if (resultImages.length) { + parts.push({ type: OPENAI_BLOCK.TEXT, text: `[Image from tool result ${block.tool_use_id}]` }); + parts.push(...resultImages); + } break; + } } } diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index 194078ae..d0a55e43 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -42,16 +42,19 @@ function toNativeImageBlock(part) { type: OPENAI_BLOCK.IMAGE, image: encodeDataUri(parsed.mimeType, parsed.base64), mimeType: parsed.mimeType, + mediaType: parsed.mimeType, }; } if (part.type === OPENAI_BLOCK.IMAGE || part.type === CLAUDE_BLOCK.IMAGE) { if (typeof part.image === "string" && part.image.startsWith("data:")) { const parsed = parseDataUri(part.image); + const mime = part.mimeType || parsed?.mimeType || "image/png"; return { type: OPENAI_BLOCK.IMAGE, image: part.image, - mimeType: part.mimeType || parsed?.mimeType || "image/png", + mimeType: mime, + mediaType: mime, }; } const source = part.source; @@ -61,6 +64,7 @@ function toNativeImageBlock(part) { type: OPENAI_BLOCK.IMAGE, image: encodeDataUri(mime, source.data), mimeType: mime, + mediaType: mime, }; } } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index dbaefbae..0c9f04fd 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -434,6 +434,13 @@ export function openaiToKiroRequest(model, body, stream, credentials) { enumerable: false }); + // Kiro tool specs get sanitized names (`mcp__a__b` → `mcp_a_b`); keep the + // reverse map so tool calls stream back under the client's own names. + const restoredToolNames = new Map(); + for (const [original, sanitized] of nameMap) { + if (original !== sanitized) restoredToolNames.set(sanitized, original); + } + if (restoredToolNames.size) payload._toolNameMap = restoredToolNames; return payload; } diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index 455672b1..9118b9cb 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -46,6 +46,14 @@ function convertFinishReason(reason) { * Convert one OpenAI-format chunk (from KiroExecutor) into Claude SSE events. * Returns an array of Claude events, or null when the chunk yields nothing. */ +// Kiro only accepts sanitized tool names; the request translator leaves the +// reverse map on the stream state so calls come back under the client's names. +function restoreToolName(state, name) { + const raw = name || ""; + const map = state?.toolNameMap; + return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw; +} + export function kiroToClaudeResponse(chunk, state) { // KiroExecutor emits chat.completion.chunk objects; tolerate string chunks // by attempting a parse (defensive — the direct path is always objects). @@ -161,7 +169,7 @@ export function kiroToClaudeResponse(chunk, state) { const toolBlockIndex = state.nextBlockIndex++; state.toolCalls.set(idx, { id: tc.id, - name: tc.function?.name || "", + name: restoreToolName(state, tc.function?.name), blockIndex: toolBlockIndex, }); results.push({ @@ -170,7 +178,7 @@ export function kiroToClaudeResponse(chunk, state) { content_block: { type: "tool_use", id: tc.id, - name: tc.function?.name || "", + name: restoreToolName(state, tc.function?.name), input: {}, }, }); @@ -246,7 +254,7 @@ export function kiroToClaudeNonStreaming(data) { content.push({ type: "tool_use", id: tc.id || `toolu_${Date.now()}`, - name: tc.function?.name || "", + name: restoreToolName(state, tc.function?.name), input, }); } diff --git a/open-sse/translator/response/kiro-to-openai.js b/open-sse/translator/response/kiro-to-openai.js index 7059a851..8e713fd7 100644 --- a/open-sse/translator/response/kiro-to-openai.js +++ b/open-sse/translator/response/kiro-to-openai.js @@ -20,13 +20,38 @@ function chunkMeta(state) { * Parse Kiro SSE event and convert to OpenAI format * Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc. */ +// Kiro only accepts sanitized tool names; the request translator leaves the +// reverse map on the stream state so calls come back under the client's names. +function restoreToolName(state, name) { + const raw = name || ""; + const map = state?.toolNameMap; + return map && typeof map.get === "function" && map.has(raw) ? map.get(raw) : raw; +} + export function kiroToOpenAIResponse(chunk, state) { if (!chunk) return null; - // If chunk is already in OpenAI format (from executor transform), return as-is + // If chunk is already in OpenAI format (from executor transform), return it + // with the client's tool names restored. if (chunk.object === "chat.completion.chunk" && chunk.choices) { - return chunk; + if (!state?.toolNameMap?.size) return chunk; + return { + ...chunk, + choices: chunk.choices.map((choice) => { + const calls = choice?.delta?.tool_calls; + if (!Array.isArray(calls)) return choice; + return { + ...choice, + delta: { + ...choice.delta, + tool_calls: calls.map((tc) => tc?.function?.name + ? { ...tc, function: { ...tc.function, name: restoreToolName(state, tc.function.name) } } + : tc), + }, + }; + }), + }; } // Handle string chunk (raw SSE data) @@ -109,7 +134,7 @@ export function kiroToOpenAIResponse(chunk, state) { state.hadToolUse = true; const toolUse = data.toolUseEvent || data; const toolCallId = toolUse.toolUseId || fallbackToolCallId(); - const toolName = toolUse.name || ""; + const toolName = restoreToolName(state, toolUse.name); const toolInput = toolUse.input || {}; const openaiChunk = buildChunk(chunkMeta(state), { diff --git a/tests/translator/agent-client-fixes.test.js b/tests/translator/agent-client-fixes.test.js new file mode 100644 index 00000000..3f4e7cd7 --- /dev/null +++ b/tests/translator/agent-client-fixes.test.js @@ -0,0 +1,186 @@ +// Fixes for agent clients (Claude Code) driving non-Anthropic upstreams: +// - tool-result images survive the Claude → OpenAI / Kiro request translation +// - Kiro tool calls stream back under the client's own (unsanitized) names +// - the client's thinking `display` is kept on Claude-format upstreams +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest, translateResponse, initState } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { applyThinking } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import { kiroToClaudeResponse } from "../../open-sse/translator/response/kiro-to-claude.js"; +import { kiroToOpenAIResponse } from "../../open-sse/translator/response/kiro-to-openai.js"; +import { selectAnthropicBeta } from "../../open-sse/providers/shared.js"; +import { hoistToolResultImages } from "../../open-sse/translator/formats/claude.js"; +import { openaiToCommandCodeRequest } from "../../open-sse/translator/request/openai-to-commandcode.js"; + +const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; + +const screenshotTurn = (extraTools = []) => ({ + tools: [ + { name: "mcp__browser__computer", description: "browser", input_schema: { type: "object", properties: {} } }, + ...extraTools, + ], + messages: [ + { role: "user", content: "take a screenshot" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "mcp__browser__computer", input: { action: "screenshot" } }] }, + { + role: "user", + content: [{ + type: "tool_result", + tool_use_id: "toolu_1", + content: [ + { type: "text", text: "Successfully captured screenshot (1x1, png)" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: PNG } }, + ], + }], + }, + ], +}); + +describe("tool-result images reach OpenAI-format upstreams", () => { + it("emits the tool message text and a follow-up user message carrying the image", () => { + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "gpt-x", screenshotTurn(), true, null, "openai"); + const toolMsg = out.messages.find((m) => m.role === "tool"); + expect(toolMsg.tool_call_id).toBe("toolu_1"); + expect(toolMsg.content).toBe("Successfully captured screenshot (1x1, png)"); + expect(toolMsg.content).not.toContain(PNG); + const follow = out.messages[out.messages.indexOf(toolMsg) + 1]; + expect(follow.role).toBe("user"); + const image = follow.content.find((p) => p.type === "image_url"); + expect(image.image_url.url).toBe(`data:image/png;base64,${PNG}`); + expect(follow.content.find((p) => p.type === "text").text).toContain("toolu_1"); + }); + + it("does not dump base64 into a tool message that had no text", () => { + const body = screenshotTurn(); + body.messages[2].content[0].content = [{ type: "image", source: { type: "base64", media_type: "image/png", data: PNG } }]; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "gpt-x", body, true, null, "openai"); + const toolMsg = out.messages.find((m) => m.role === "tool"); + expect(toolMsg.content).toBe(""); + expect(out.messages.some((m) => Array.isArray(m.content) && m.content.some((p) => p.type === "image_url"))).toBe(true); + }); + + it("leaves text-only tool results exactly as before", () => { + const body = screenshotTurn(); + body.messages[2].content[0].content = "plain result"; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "gpt-x", body, true, null, "openai"); + const toolMsg = out.messages.find((m) => m.role === "tool"); + expect(toolMsg.content).toBe("plain result"); + expect(out.messages[out.messages.length - 1]).toBe(toolMsg); + }); + + it("forwards tool-result images to Kiro as user images", () => { + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", screenshotTurn(), true, null, "kiro"); + const json = JSON.stringify(out.conversationState); + expect(json).toContain(PNG); + expect(json).toContain("Successfully captured screenshot"); + }); +}); + +describe("Kiro tool names round-trip", () => { + it("returns the sanitized→original map on the translated body", () => { + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", screenshotTurn(), true, null, "kiro"); + expect(out._toolNameMap).toBeInstanceOf(Map); + expect(out._toolNameMap.get("mcp_browser_computer")).toBe("mcp__browser__computer"); + const wire = JSON.parse(JSON.stringify(out.conversationState)); + expect(JSON.stringify(wire)).toContain("mcp_browser_computer"); + expect(JSON.stringify(wire)).not.toContain("mcp__browser__computer"); + }); + + it("omits the map when no name changed", () => { + const body = screenshotTurn(); + body.tools = [{ name: "plain_tool", description: "x", input_schema: { type: "object", properties: {} } }]; + body.messages[1].content[0].name = "plain_tool"; + const out = translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro"); + expect(out._toolNameMap).toBeUndefined(); + }); + + it("restores the client name on streamed Claude tool_use blocks", () => { + const state = { ...initState(FORMATS.CLAUDE), toolNameMap: new Map([["mcp_browser_computer", "mcp__browser__computer"]]) }; + const chunk = { + id: "c1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "mcp_browser_computer", arguments: "" } }] }, finish_reason: null }], + }; + const events = kiroToClaudeResponse(chunk, state); + const start = events.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + expect(start.content_block.name).toBe("mcp__browser__computer"); + }); + + it("passes unknown names through untouched", () => { + const state = { ...initState(FORMATS.CLAUDE), toolNameMap: new Map([["mcp_browser_computer", "mcp__browser__computer"]]) }; + const chunk = { + id: "c1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_2", type: "function", function: { name: "other_tool", arguments: "" } }] }, finish_reason: null }], + }; + const events = kiroToClaudeResponse(chunk, state); + const start = events.find((e) => e.type === "content_block_start" && e.content_block?.type === "tool_use"); + expect(start.content_block.name).toBe("other_tool"); + }); + + it("restores the client name on OpenAI chunks passed through kiro-to-openai", () => { + const state = { ...initState(FORMATS.OPENAI), toolNameMap: new Map([["mcp_browser_computer", "mcp__browser__computer"]]) }; + const chunk = { + id: "c1", object: "chat.completion.chunk", created: 1, model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "mcp_browser_computer", arguments: "{}" } }] }, finish_reason: null }], + }; + const out = kiroToOpenAIResponse(chunk, state); + expect(out.choices[0].delta.tool_calls[0].function.name).toBe("mcp__browser__computer"); + expect(kiroToOpenAIResponse(chunk, initState(FORMATS.OPENAI))).toBe(chunk); + }); +}); + +describe("thinking display is preserved for Claude-format upstreams", () => { + it("keeps display on adaptive thinking", () => { + const body = { model: "claude-sonnet-5", thinking: { type: "adaptive", display: "summarized" }, output_config: { effort: "high" }, messages: [] }; + applyThinking(FORMATS.CLAUDE, "claude-sonnet-5", body, "claude"); + expect(body.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(body.output_config).toEqual({ effort: "high" }); + }); + + it("keeps display on budget thinking and omits it when the client sent none", () => { + const withDisplay = { model: "claude-haiku-4-5-20251001", thinking: { type: "adaptive", display: "omitted" }, output_config: { effort: "low" }, messages: [] }; + applyThinking(FORMATS.CLAUDE, "claude-haiku-4-5-20251001", withDisplay, "claude"); + expect(withDisplay.thinking.type).toBe("enabled"); + expect(withDisplay.thinking.display).toBe("omitted"); + + const without = { model: "claude-sonnet-5", thinking: { type: "adaptive" }, output_config: { effort: "high" }, messages: [] }; + applyThinking(FORMATS.CLAUDE, "claude-sonnet-5", without, "claude"); + expect(without.thinking).toEqual({ type: "adaptive" }); + }); +}); + +describe("redact-thinking beta follows the client's display request", () => { + it("keeps redact-thinking by default and drops it for summarized display", () => { + expect(selectAnthropicBeta("claude-sonnet-5")).toContain("redact-thinking-2026-02-12"); + expect(selectAnthropicBeta("claude-sonnet-5", { thinking: { type: "adaptive", display: "omitted" } })).toContain("redact-thinking-2026-02-12"); + const summarized = selectAnthropicBeta("claude-sonnet-5", { thinking: { type: "adaptive", display: "summarized" } }); + expect(summarized).not.toContain("redact-thinking-2026-02-12"); + expect(summarized).toContain("interleaved-thinking-2025-05-14"); + expect(summarized).toContain("effort-2025-11-24"); + }); +}); + +describe("tool-result images reach Anthropic-compatible and Command Code upstreams", () => { + it("hoists a tool_result image into the same user turn after the results", () => { + const body = screenshotTurn(); + const out = hoistToolResultImages(body); + const user = out.messages[2]; + expect(user.content[0].type).toBe("tool_result"); + expect(user.content[0].content.every((c) => c.type !== "image")).toBe(true); + expect(user.content.some((c) => c.type === "image" && c.source?.data === PNG)).toBe(true); + expect(user.content.find((c) => c.type === "text" && /toolu_1/.test(c.text))).toBeTruthy(); + // No image: untouched object identity. + const plain = { messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "x", content: "ok" }] }] }; + expect(hoistToolResultImages(plain)).toBe(plain); + }); + + it("sends an image block to Command Code instead of a placeholder", () => { + const openaiBody = translateRequest(FORMATS.CLAUDE, FORMATS.OPENAI, "muse-spark", screenshotTurn(), true, null, "commandcode"); + const out = openaiToCommandCodeRequest("muse-spark", openaiBody, true); + const json = JSON.stringify(out); + expect(json).not.toContain("[image omitted]"); + expect(json).toContain(`"type":"image"`); + expect(json).toContain(`data:image/png;base64,${PNG}`); + expect(json).toContain(`"mediaType":"image/png"`); + }); +});