From 12ab83a45dff0a24512ff16a952df348c5d10f0b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 20 May 2026 18:02:08 -0300 Subject: [PATCH] fix(translator): fix 3 Kiro tool_result defects causing 400 on follow-up turns - map is_error to status "error"/"success" instead of hardcoding "success" - add serializeToolResultContent() to handle image/JSON content blocks; avoids sending text:\"\" which Kiro rejects as improperly formed - use deterministic uuidv5 for toolUseId when tool_call has no id, preventing id mismatch between assistant toolUse and subsequent tool_result Closes #2446 --- open-sse/translator/request/openai-to-kiro.ts | 54 +++- tests/unit/translator-openai-to-kiro.test.ts | 282 ++++++++++++++++++ 2 files changed, 326 insertions(+), 10 deletions(-) diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 3404cc4507..0492fdff66 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -78,6 +78,41 @@ function normalizeKiroToolSchema(schema: unknown): Record { return result; } +function serializeToolResultContent(content: unknown): string { + if (typeof content === "string") { + return content || "(no output)"; + } + if (!Array.isArray(content)) { + if (content !== null && content !== undefined) { + try { + return JSON.stringify(content); + } catch { + return "(no output)"; + } + } + return "(no output)"; + } + const parts: string[] = []; + for (const block of content as Array>) { + if (!block || typeof block !== "object") continue; + if (block.type === "text" && typeof block.text === "string") { + if (block.text) parts.push(block.text); + } else if (block.type === "image" || block.type === "image_url") { + const src = block.source as Record | undefined; + const mediaType = src?.media_type ?? block.media_type ?? "image"; + parts.push(`[image: ${mediaType}]`); + } else { + try { + const str = JSON.stringify(block); + if (str && str !== "{}") parts.push(str); + } catch { + // skip unserializable block + } + } + } + return parts.join("\n") || "(no output)"; +} + /** * Convert OpenAI messages to Kiro format * Rules: system/tool/user -> user role, merge consecutive same roles @@ -237,15 +272,10 @@ function convertMessages(messages, tools, model) { const toolResultBlocks = msg.content.filter((c) => c.type === "tool_result"); if (toolResultBlocks.length > 0) { toolResultBlocks.forEach((block) => { - const text = Array.isArray(block.content) - ? block.content.map((c) => c.text || "").join("\n") - : typeof block.content === "string" - ? block.content - : ""; - + const text = serializeToolResultContent(block.content); pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: text }], }); }); @@ -300,16 +330,20 @@ function convertMessages(messages, tools, model) { const lastMsg = history[history.length - 1]; if (lastMsg?.assistantResponseMessage) { - lastMsg.assistantResponseMessage.toolUses = toolUses.map((tc) => { + const NAMESPACE_KIRO_TOOLUSE = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + lastMsg.assistantResponseMessage.toolUses = toolUses.map((tc, idx) => { if (tc.function) { + const stableId = + tc.id || uuidv5(`${tc.function.name}:${idx}`, NAMESPACE_KIRO_TOOLUSE); return { - toolUseId: tc.id || uuidv4(), + toolUseId: stableId, name: tc.function.name, input: parseToolInput(tc.function.arguments), }; } else { + const stableId = tc.id || uuidv5(`${tc.name}:${idx}`, NAMESPACE_KIRO_TOOLUSE); return { - toolUseId: tc.id || uuidv4(), + toolUseId: stableId, name: tc.name, input: parseToolInput(tc.input), }; diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 53284396ff..d14d3e3c29 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -584,3 +584,285 @@ test("OpenAI -> Kiro includes origin on all history user messages", () => { // Note: last user message becomes currentMessage, not history assert.equal(history.length, 2); }); + +// ── Defeito 1: status hardcoded como "success" ────────────────────────────── + +test("OpenAI -> Kiro maps tool_result is_error:true to status:'error'", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Run a tool" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_err", name: "bash", input: { cmd: "fail" } }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_err", + is_error: true, + content: [{ type: "text", text: "Command not found" }], + }, + ], + }, + { role: "user", content: "What happened?" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + toolResults?: Array<{ toolUseId: string; status: string; content: Array<{ text: string }> }>; + }; + assert.ok(ctx?.toolResults, "toolResults should be present"); + const errorResult = ctx.toolResults!.find((tr) => tr.toolUseId === "call_err"); + assert.ok(errorResult, "tool result for call_err should exist"); + assert.equal(errorResult!.status, "error", "is_error:true must map to status:'error'"); + assert.equal(errorResult!.content[0].text, "Command not found"); +}); + +test("OpenAI -> Kiro maps tool_result is_error:false to status:'success'", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Run a tool" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_ok", name: "bash", input: { cmd: "echo hi" } }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_ok", + is_error: false, + content: [{ type: "text", text: "hi" }], + }, + ], + }, + { role: "user", content: "Done" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + toolResults?: Array<{ toolUseId: string; status: string }>; + }; + const okResult = ctx?.toolResults?.find((tr) => tr.toolUseId === "call_ok"); + assert.ok(okResult, "tool result for call_ok should exist"); + assert.equal(okResult!.status, "success"); +}); + +// ── Defeito 2: conteúdo não-texto colapsa para string vazia ───────────────── + +test("OpenAI -> Kiro serializes image tool_result content to non-empty text", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Analyze image" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_img", name: "capture_screen", input: {} }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_img", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "abc123" }, + }, + ], + }, + ], + }, + { role: "user", content: "What do you see?" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + toolResults?: Array<{ toolUseId: string; content: Array<{ text: string }> }>; + }; + const imgResult = ctx?.toolResults?.find((tr) => tr.toolUseId === "call_img"); + assert.ok(imgResult, "tool result should exist"); + const text = imgResult!.content[0].text; + assert.ok(text && text.length > 0, `text must not be empty for image content, got: '${text}'`); +}); + +test("OpenAI -> Kiro serializes JSON-object tool_result content to non-empty text", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Search files" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "call_json", name: "list_files", input: { path: "/tmp" } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_json", + content: [{ type: "json", data: { files: ["a.txt", "b.ts"] } }], + }, + ], + }, + { role: "user", content: "Thanks" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + toolResults?: Array<{ toolUseId: string; content: Array<{ text: string }> }>; + }; + const jsonResult = ctx?.toolResults?.find((tr) => tr.toolUseId === "call_json"); + assert.ok(jsonResult, "tool result should exist"); + const text = jsonResult!.content[0].text; + assert.ok(text && text.length > 0, `text must be non-empty, got: '${text}'`); +}); + +test("OpenAI -> Kiro uses placeholder text when tool_result content is empty array", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Do something" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_empty", name: "no_output_tool", input: {} }], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "call_empty", + content: [], + }, + ], + }, + { role: "user", content: "Continue" }, + ], + }, + false, + null + ); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + toolResults?: Array<{ toolUseId: string; content: Array<{ text: string }> }>; + }; + const emptyResult = ctx?.toolResults?.find((tr) => tr.toolUseId === "call_empty"); + assert.ok(emptyResult, "tool result should exist"); + const text = emptyResult!.content[0].text; + assert.ok(text && text.length > 0, `placeholder text must be non-empty, got: '${text}'`); +}); + +// ── Defeito 3: instabilidade do toolUseId ─────────────────────────────────── + +test("OpenAI -> Kiro toolUseId round-trips between tool_use and tool_result in 2-turn conversation", () => { + // Regressão para issue #2446: conversa 2 turnos (tool_use → tool_result → follow-up) + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Create a folder on the desktop" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01abc", + name: "bash", + input: { cmd: "mkdir ~/Desktop/new_folder" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_01abc", + is_error: false, + content: [{ type: "text", text: "" }], + }, + ], + }, + { role: "user", content: "Done! What next?" }, + ], + }, + false, + null + ); + + const historyAssistant = (result.conversationState.history as any[]).find( + (h) => h.assistantResponseMessage?.toolUses + ); + assert.ok(historyAssistant, "assistant turn with toolUses must be in history"); + const toolUse = historyAssistant.assistantResponseMessage.toolUses[0]; + assert.equal(toolUse.toolUseId, "toolu_01abc", "toolUseId must be preserved from tool_use.id"); + + const ctx = result.conversationState.currentMessage.userInputMessage.userInputMessageContext as { + toolResults?: Array<{ toolUseId: string; status: string }>; + }; + assert.ok(ctx?.toolResults, "toolResults must be present in currentMessage context"); + const tr = ctx.toolResults!.find((r) => r.toolUseId === "toolu_01abc"); + assert.ok(tr, "toolResult must reference the same toolUseId 'toolu_01abc'"); + assert.equal(tr!.status, "success"); +}); + +test("OpenAI -> Kiro generates stable non-random toolUseId when tool_call has no id", () => { + const makePayload = () => + buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Start" }, + { + role: "assistant", + tool_calls: [ + { + type: "function", + function: { name: "read_file", arguments: '{"path":"/tmp/x"}' }, + }, + ], + }, + { role: "user", content: "Continue" }, + ], + }, + false, + null + ); + + const id1 = (makePayload().conversationState.history as any[]).find( + (h) => h.assistantResponseMessage?.toolUses + )?.assistantResponseMessage?.toolUses?.[0]?.toolUseId; + + const id2 = (makePayload().conversationState.history as any[]).find( + (h) => h.assistantResponseMessage?.toolUses + )?.assistantResponseMessage?.toolUses?.[0]?.toolUseId; + + assert.ok(id1, "toolUseId must be set even when id is absent"); + assert.equal(id1, id2, "toolUseId must be deterministic (same input → same id)"); +});