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.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(",");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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), {
|
||||
|
||||
186
tests/translator/agent-client-fixes.test.js
Normal file
186
tests/translator/agent-client-fixes.test.js
Normal file
@@ -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"`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user