mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
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:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user