diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 716ec861..d3a502f3 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -12,22 +12,182 @@ import { KIRO_AGENTIC_SYSTEM_PROMPT } from "../../config/kiroConstants.js"; +/** Render a single tool call as a readable text line. */ +function toolCallToText(name, input) { + let argStr; + try { + argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); + } catch { + argStr = "{}"; + } + return `[Tool call: ${name || "unknown"}(${argStr})]`; +} + +/** Render a tool result (string or content-block array) as a text line. */ +function toolResultToText(content) { + const text = Array.isArray(content) + ? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n") + : (typeof content === "string" ? content : ""); + return `[Tool result: ${text}]`; +} + +/** + * Flatten all tool calls/results in a conversation into plain text. + * + * Kiro's schema validator requires a non-empty + * currentMessage.userInputMessageContext.tools array whenever the history + * references any tool use; otherwise it returns "Improperly formed request" + * (HTTP 400). A client can hit this by omitting the `tools` array on a + * follow-up request — typically after client-side compaction (e.g. OpenCode). + * + * Rather than fabricate stub tool specs — which would advertise tool-calling + * capability the client never requested and may not handle, risking a phantom + * tool call on an otherwise plain turn — we collapse the tool interaction into + * text. The request stays honest, and since no structured tool content + * remains, the validator's "tools required" rule never fires. + * + * Only invoked when the client did NOT send tools; when tools are present the + * structured form is preserved. + */ +function flattenToolInteractions(messages) { + const out = []; + + for (const msg of messages) { + // OpenAI tool-result message → user text line + if (msg.role === "tool") { + out.push({ role: "user", content: toolResultToText(msg.content) }); + continue; + } + + if (msg.role === "assistant") { + const parts = []; + if (Array.isArray(msg.content)) { + for (const c of msg.content) { + if (c.type === "tool_use") { + parts.push(toolCallToText(c.name, c.input)); + } else if (c.type === "text" || c.text) { + parts.push(c.text || ""); + } + } + } else if (typeof msg.content === "string") { + parts.push(msg.content); + } + for (const tc of msg.tool_calls || []) { + parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); + } + out.push({ role: "assistant", content: parts.filter(Boolean).join("\n") }); + continue; + } + + // User messages: replace tool_result blocks with text, keep text + images. + if (msg.role === "user" && Array.isArray(msg.content)) { + const newContent = msg.content.map(c => + c.type === "tool_result" + ? { type: "text", text: toolResultToText(c.content) } + : c + ); + out.push({ ...msg, content: newContent }); + continue; + } + + out.push(msg); + } + + return out; +} + +/** + * Reconcile orphaned toolResults — those whose toolUseId has no matching + * toolUse in any assistant message. This happens when client-side compaction + * truncates the conversation and removes the assistant message containing the + * tool_use, but keeps the user message with the corresponding tool_result. + * + * A dangling structured reference makes Kiro return 400, so it must be removed. + * But the client deliberately kept the result content through compaction, so + * rather than discard it we fold it back into the user message as text — the + * same shape flattenToolInteractions() produces. The 400 trigger (the + * structured reference) is gone; the content survives. + * + * `messages` is every carrier that can hold toolResults — both history items + * and the popped-out currentMessage (orphans can land on either). + */ +function reconcileOrphanedToolResults(history, currentMessage) { + // Phase 1: collect all valid toolUseIds from assistant messages in history. + // (currentMessage is always a user turn, so it carries no toolUses.) + const validIds = new Set(); + for (const h of history) { + const arm = h.assistantResponseMessage; + if (!arm) continue; + for (const tu of arm.toolUses || []) { + if (tu.toolUseId) validIds.add(tu.toolUseId); + } + } + + // Phase 2: across history + currentMessage, keep results with a matching + // toolUse and salvage the rest as text. + const carriers = currentMessage ? [...history, currentMessage] : history; + for (const item of carriers) { + const uim = item.userInputMessage; + const ctx = uim?.userInputMessageContext; + if (!ctx?.toolResults?.length) continue; + + const kept = []; + const salvaged = []; + for (const tr of ctx.toolResults) { + if (validIds.has(tr.toolUseId)) { + kept.push(tr); + } else { + salvaged.push(toolResultToText(tr.content)); + } + } + + if (salvaged.length === 0) continue; // no orphans — leave untouched + + // Fold orphaned result content into the user text so it is not lost + const extra = salvaged.join("\n"); + uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; + + ctx.toolResults = kept; + if (kept.length === 0 && !ctx.tools?.length) { + delete uim.userInputMessageContext; + } + } +} + +/** + * Safely parse JSON string, returning fallback on failure. + */ +function safeJSONParse(str, fallback) { + if (typeof str !== "string") return str ?? fallback; + try { return JSON.parse(str); } catch { return fallback; } +} + /** * Convert OpenAI messages to Kiro format - * Rules: system/tool/user -> user role, merge consecutive same roles + * Rules: system/tool/user -> user role, merge consecutive same roles. + * + * Returns { history, currentMessage }. */ function convertMessages(messages, tools, model) { let history = []; let currentMessage = null; - + + const clientProvidedTools = tools && tools.length > 0; + + // When the client did not send tools, flatten any tool calls/results in the + // history into plain text (see flattenToolInteractions). This keeps the + // request honest and sidesteps Kiro's "tools required" 400, since no + // structured tool content survives to trigger it. + if (!clientProvidedTools) { + messages = flattenToolInteractions(messages); + } + let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; let pendingImages = []; let currentRole = null; - - // Image support is pre-filtered by caps in translateRequest before reaching here - const supportsImages = true; + let toolsInjectedToFirstUserMsg = false; const flushPending = () => { if (currentRole === "user") { @@ -49,20 +209,23 @@ function convertMessages(messages, tools, model) { toolResults: pendingToolResults }; } - - // Add tools to first user message - if (tools && tools.length > 0 && history.length === 0) { + + // Add tools to the user message that has no preceding assistant messages, + // OR the first user message (whichever comes first after any opening + // assistant messages). We track whether any user message has already + // received tools via a flag on the history array. + if (clientProvidedTools && !toolsInjectedToFirstUserMsg) { if (!userMsg.userInputMessage.userInputMessageContext) { userMsg.userInputMessage.userInputMessageContext = {}; } userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { const name = t.function?.name || t.name; let description = t.function?.description || t.description || ""; - + if (!description.trim()) { description = `Tool: ${name}`; } - + const schema = t.function?.parameters || t.parameters || t.input_schema || {}; // Normalize schema: Kiro requires required[] and proper type/properties const normalizedSchema = Object.keys(schema).length === 0 @@ -77,8 +240,9 @@ function convertMessages(messages, tools, model) { } }; }); + toolsInjectedToFirstUserMsg = true; } - + history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -99,18 +263,18 @@ function convertMessages(messages, tools, model) { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; let role = msg.role; - + // Normalize: system/tool -> user if (role === "system" || role === "tool") { role = "user"; } - + // If role changes, flush pending if (role !== currentRole && currentRole !== null) { flushPending(); } currentRole = role; - + if (role === "user") { // Extract content let content = ""; @@ -121,7 +285,7 @@ function convertMessages(messages, tools, model) { for (const c of msg.content) { if (c.type === "text" || c.text) { textParts.push(c.text || ""); - } else if (supportsImages && c.type === "image_url") { + } else if (c.type === "image_url") { // OpenAI format: image_url.url with data URI const url = c.image_url?.url || ""; const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); @@ -133,7 +297,7 @@ function convertMessages(messages, tools, model) { // Kiro only supports base64 — fallback to URL text textParts.push(`[Image: ${url}]`); } - } else if (supportsImages && c.type === "image") { + } else if (c.type === "image") { // Claude format: source.type = "base64", source.media_type, source.data if (c.source?.type === "base64" && c.source?.data) { const mediaType = c.source.media_type || "image/png"; @@ -143,15 +307,15 @@ function convertMessages(messages, tools, model) { } } content = textParts.join("\n"); - + // Check for tool_result blocks const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); if (toolResultBlocks.length > 0) { toolResultBlocks.forEach(block => { - const text = Array.isArray(block.content) + const text = Array.isArray(block.content) ? block.content.map(c => c.text || "").join("\n") : (typeof block.content === "string" ? block.content : ""); - + pendingToolResults.push({ toolUseId: block.tool_use_id, status: "success", @@ -160,7 +324,7 @@ function convertMessages(messages, tools, model) { }); } } - + // Handle tool role (from normalized) if (msg.role === "tool") { const toolContent = typeof msg.content === "string" ? msg.content : ""; @@ -176,34 +340,30 @@ function convertMessages(messages, tools, model) { // Extract text content and tool uses let textContent = ""; let toolUses = []; - + if (Array.isArray(msg.content)) { const textBlocks = msg.content.filter(c => c.type === "text"); textContent = textBlocks.map(b => b.text).join("\n").trim(); - + const toolUseBlocks = msg.content.filter(c => c.type === "tool_use"); toolUses = toolUseBlocks; } else if (typeof msg.content === "string") { textContent = msg.content.trim(); } - + if (msg.tool_calls && msg.tool_calls.length > 0) { toolUses = msg.tool_calls; } - + if (textContent) { pendingAssistantContent.push(textContent); } - + // Store tool uses in last assistant message if (toolUses.length > 0) { - if (pendingAssistantContent.length === 0) { - // pendingAssistantContent.push("Call tools"); - } - // Flush to create assistant message with toolUses flushPending(); - + const lastMsg = history[history.length - 1]; if (lastMsg?.assistantResponseMessage) { lastMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { @@ -211,9 +371,7 @@ function convertMessages(messages, tools, model) { return { toolUseId: tc.id || uuidv4(), name: tc.function.name, - input: typeof tc.function.arguments === "string" - ? JSON.parse(tc.function.arguments) - : (tc.function.arguments || {}) + input: safeJSONParse(tc.function.arguments, {}) }; } else { return { @@ -224,17 +382,17 @@ function convertMessages(messages, tools, model) { } }); } - + currentRole = null; } } } - + // Flush remaining if (currentRole !== null) { flushPending(); } - + // Pop last userInputMessage as currentMessage (search from end, skip trailing assistant messages) for (let i = history.length - 1; i >= 0; i--) { if (history[i].userInputMessage) { @@ -261,6 +419,8 @@ function convertMessages(messages, tools, model) { }); // Merge consecutive user messages (Kiro requires alternating user/assistant) + // When merging, also combine userInputMessageContext fields so toolResults + // and images from the second message are not silently dropped. const mergedHistory = []; for (let i = 0; i < history.length; i++) { const current = history[i]; @@ -269,18 +429,63 @@ function convertMessages(messages, tools, model) { mergedHistory[mergedHistory.length - 1].userInputMessage) { const prev = mergedHistory[mergedHistory.length - 1]; prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; + // Merge context: combine toolResults, images, etc. + const prevCtx = prev.userInputMessage.userInputMessageContext; + const curCtx = current.userInputMessage.userInputMessageContext; + if (curCtx) { + if (!prevCtx) { + prev.userInputMessage.userInputMessageContext = curCtx; + } else { + if (curCtx.toolResults?.length > 0) { + prevCtx.toolResults = [...(prevCtx.toolResults || []), ...curCtx.toolResults]; + } + if (curCtx.tools?.length > 0) { + prevCtx.tools = [...(prevCtx.tools || []), ...curCtx.tools]; + } + } + } } else { mergedHistory.push(current); } } - // Inject tools into currentMessage AFTER cleanup - if (firstHistoryTools && currentMessage?.userInputMessage && + // When currentMessage is null (no user messages at all — edge case where + // input is only assistant messages), create a minimal currentMessage so + // tools and content can be injected. + if (!currentMessage) { + currentMessage = { + userInputMessage: { + content: "", + modelId: model, + } + }; + } + + // Reconcile orphaned toolResults across history AND currentMessage — when + // client-side compaction removes assistant messages containing tool_use but + // keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the + // content back into the user text instead of discarding it. Run after + // currentMessage is finalized (an orphan can be merged into it) and before + // tool injection (which may re-add userInputMessageContext). + // + // Only needed on the tools-present path: when the client sent no tools, + // flattenToolInteractions already collapsed every toolResult to text, so + // there is nothing structured left to orphan. + if (clientProvidedTools) { + reconcileOrphanedToolResults(mergedHistory, currentMessage); + } + + // Inject tools into currentMessage AFTER cleanup. Tools only exist here when + // the client explicitly sent them (otherwise flattenToolInteractions already + // collapsed all tool content to text upstream, so there is nothing to carry). + const resolvedTools = firstHistoryTools; + + if (resolvedTools?.length > 0 && !currentMessage.userInputMessage.userInputMessageContext?.tools) { if (!currentMessage.userInputMessage.userInputMessageContext) { currentMessage.userInputMessage.userInputMessageContext = {}; } - currentMessage.userInputMessage.userInputMessageContext.tools = firstHistoryTools; + currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools; } return { history: mergedHistory, currentMessage }; @@ -318,6 +523,7 @@ export function buildKiroPayload(model, body, stream, credentials) { const profileArn = credentials?.providerSpecificData?.profileArn || ""; let finalContent = currentMessage?.userInputMessage?.content || ""; + const timestamp = new Date().toISOString(); // Build the system-prompt prefix that goes ABOVE the user message body. diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index 3e82ccb9..3c2bc025 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -143,4 +143,141 @@ describe("buildKiroPayload", () => { expect(currentMsg.userInputMessage.content).toContain("[Image: https://example.com/photo.jpg]"); }); }); + + describe("tool interaction without client-provided tools", () => { + // When the client omits `tools` (e.g. after compaction), structured tool + // content must be flattened to text so Kiro's "tools required" 400 never + // fires and no phantom tool-calling capability is advertised. + + it("should flatten OpenAI tool_calls + tool result into history text with no tools array", () => { + const body = { + messages: [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.txt"}' } } + ] + }, + { role: "tool", tool_call_id: "call_1", content: "file contents here" }, + { role: "user", content: "Summarize it" } + ] + // note: no `tools` + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + + // No structured tool content anywhere + expect(cs.currentMessage.userInputMessage.userInputMessageContext).toBeUndefined(); + const allJson = JSON.stringify(cs); + expect(allJson).not.toContain("toolUses"); + expect(allJson).not.toContain("toolResults"); + + // Tool call + result preserved as readable text (call lands in history, + // result merges into the final currentMessage — assert across both) + expect(allJson).toContain("[Tool call: read_file("); + expect(allJson).toContain("[Tool result: file contents here]"); + }); + + it("should flatten Claude tool_use / tool_result blocks with no tools array", () => { + const body = { + messages: [ + { role: "user", content: "Do it" }, + { + role: "assistant", + content: [ + { type: "text", text: "Calling tool" }, + { type: "tool_use", id: "tu_1", name: "search", input: { q: "kiro" } } + ] + }, + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "tu_1", content: "result text" } + ] + } + ] + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + + const allJson = JSON.stringify(cs); + expect(allJson).not.toContain("toolUses"); + expect(allJson).not.toContain("toolResults"); + expect(allJson).toContain("[Tool call: search("); + expect(allJson).toContain("[Tool result: result text]"); + }); + + it("should keep structured tools when the client DOES provide a tools array", () => { + const body = { + messages: [ + { role: "user", content: "Read the file" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read_file", arguments: '{"path":"a.txt"}' } } + ] + }, + { role: "tool", tool_call_id: "call_1", content: "file contents here" }, + { role: "user", content: "Summarize it" } + ], + tools: [ + { + type: "function", + function: { name: "read_file", description: "Read a file", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } + } + ] + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + + // Structured tool spec carried on currentMessage + const tools = cs.currentMessage.userInputMessage.userInputMessageContext?.tools; + expect(tools).toBeDefined(); + expect(tools[0].toolSpecification.name).toBe("read_file"); + + // Structured tool history preserved (not flattened to text) + const allJson = JSON.stringify(cs); + expect(allJson).toContain("toolUses"); + expect(allJson).not.toContain("[Tool call:"); + }); + + it("should salvage orphaned tool_result content as text instead of discarding it", () => { + // Client provides tools, but compaction removed the assistant tool_use + // message, leaving a tool_result whose tool_use_id matches nothing. + const body = { + messages: [ + { role: "user", content: "Start" }, + // (assistant tool_use for "orphan_call" was compacted away) + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "orphan_call", content: "important orphaned output" } + ] + }, + { role: "user", content: "Now continue" } + ], + tools: [ + { + type: "function", + function: { name: "some_tool", description: "x", parameters: { type: "object", properties: {}, required: [] } } + } + ] + }; + + const result = buildKiroPayload("claude-sonnet-4.6", body, true, {}); + const cs = result.conversationState; + const allJson = JSON.stringify(cs); + + // The dangling structured reference is gone (would trigger Kiro 400)... + expect(allJson).not.toContain("orphan_call"); + // ...but the content is preserved as salvaged text, not discarded. + expect(allJson).toContain("[Tool result: important orphaned output]"); + }); + }); });