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
This commit is contained in:
diegosouzapw
2026-05-20 18:02:08 -03:00
parent 48c9b4a986
commit 12ab83a45d
2 changed files with 326 additions and 10 deletions

View File

@@ -78,6 +78,41 @@ function normalizeKiroToolSchema(schema: unknown): Record<string, unknown> {
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<Record<string, unknown>>) {
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<string, unknown> | 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),
};

View File

@@ -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)");
});