diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 1eb87198dc..0e58e26070 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -27,19 +27,55 @@ function parseToolInput(value: unknown) { } } -function normalizeKiroToolSchema(schema: unknown) { +/** + * Recursively sanitize JSON Schema for Kiro API. + * Kiro returns 400 "Improperly formed request" if: + * - `required` is an empty array [] + * - `additionalProperties` is present anywhere + */ +function normalizeKiroToolSchema(schema: unknown): Record { if (!schema || typeof schema !== "object" || Array.isArray(schema)) { - return { type: "object", properties: {}, required: [] }; + return { type: "object", properties: {} }; } - return { - type: "object", - properties: {}, - ...(schema as Record), - required: Array.isArray((schema as { required?: unknown }).required) - ? (schema as { required: unknown[] }).required - : [], - }; + const result: Record = {}; + const src = schema as Record; + + for (const [key, value] of Object.entries(src)) { + // Skip empty required arrays — Kiro rejects them + if (key === "required" && Array.isArray(value) && value.length === 0) { + continue; + } + // Skip additionalProperties — Kiro doesn't support it + if (key === "additionalProperties") { + continue; + } + // Recursively process nested objects + if ( + key === "properties" && + typeof value === "object" && + value !== null && + !Array.isArray(value) + ) { + const sanitizedProps: Record = {}; + for (const [propName, propValue] of Object.entries(value as Record)) { + sanitizedProps[propName] = normalizeKiroToolSchema(propValue); + } + result[key] = sanitizedProps; + } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { + result[key] = normalizeKiroToolSchema(value); + } else if (Array.isArray(value)) { + result[key] = value.map((item) => + typeof item === "object" && item !== null && !Array.isArray(item) + ? normalizeKiroToolSchema(item) + : item + ); + } else { + result[key] = value; + } + } + + return result; } /** @@ -57,11 +93,12 @@ function convertMessages(messages, tools, model) { const flushPending = () => { if (currentRole === "user") { - const content = pendingUserContent.join("\n\n").trim() || "continue"; + const content = pendingUserContent.join("\n\n").trim() || "(empty)"; const userMsg: { userInputMessage: { content: string; modelId: string; + origin: string; userInputMessageContext?: { toolResults?: Array>; tools?: Array>; @@ -71,6 +108,7 @@ function convertMessages(messages, tools, model) { userInputMessage: { content: content, modelId: "", + origin: "AI_EDITOR", }, }; @@ -112,7 +150,7 @@ function convertMessages(messages, tools, model) { pendingUserContent = []; pendingToolResults = []; } else if (currentRole === "assistant") { - const content = pendingAssistantContent.join("\n\n").trim() || "..."; + const content = pendingAssistantContent.join("\n\n").trim() || "(empty)"; const assistantMsg = { assistantResponseMessage: { content: content, @@ -286,6 +324,11 @@ function convertMessages(messages, tools, model) { if (item.userInputMessage && !item.userInputMessage.modelId) { item.userInputMessage.modelId = model; } + + // Kiro API requires `origin` on every userInputMessage + if (item.userInputMessage && !item.userInputMessage.origin) { + item.userInputMessage.origin = "AI_EDITOR"; + } }); // Kiro expects history to alternate between user and assistant turns. After @@ -326,12 +369,119 @@ function convertMessages(messages, tools, model) { previous.userInputMessage.userInputMessageContext = mergedContext; } + } else if (item.assistantResponseMessage && previous?.assistantResponseMessage) { + // Kiro API also rejects consecutive assistant messages. Merge them. + const previousContent = previous.assistantResponseMessage.content || ""; + const currentContent = item.assistantResponseMessage.content || ""; + previous.assistantResponseMessage.content = previousContent + ? `${previousContent}\n\n${currentContent}` + : currentContent; + + if (item.assistantResponseMessage.toolUses) { + const existingToolUses = previous.assistantResponseMessage.toolUses || []; + previous.assistantResponseMessage.toolUses = [ + ...existingToolUses, + ...item.assistantResponseMessage.toolUses, + ]; + } } else { mergedHistory.push(item); } } - return { history: mergedHistory, currentMessage }; + // Ensure first message is user. Kiro API requires conversations to start + // with a user message (fixes "Improperly formed request" for assistant-first). + if (mergedHistory.length > 0 && mergedHistory[0].assistantResponseMessage) { + mergedHistory.unshift({ + userInputMessage: { + content: "(empty)", + modelId: model, + origin: "AI_EDITOR", + }, + }); + } + + // Ensure assistant exists before toolResults. Kiro API validates that every + // toolResults array has a preceding assistantResponseMessage with toolUses. + // When the assistant message is missing (truncated conversation), we strip + // the orphaned toolResults and convert them to text to preserve context. + for (let i = 0; i < mergedHistory.length; i++) { + const item = mergedHistory[i]; + if (!item.userInputMessage?.userInputMessageContext?.toolResults) continue; + + const prev = mergedHistory[i - 1]; + const hasPrecedingAssistant = + prev?.assistantResponseMessage?.toolUses && prev.assistantResponseMessage.toolUses.length > 0; + + if (!hasPrecedingAssistant) { + const toolResults = item.userInputMessage.userInputMessageContext.toolResults as Array<{ + toolUseId?: string; + content?: Array<{ text?: string }>; + }>; + const toolResultTexts = toolResults + .map((tr) => { + const id = tr.toolUseId || ""; + const text = tr.content?.map((c) => c.text || "").join("\n") || ""; + return id ? `[Tool Result (${id})]\n${text}` : `[Tool Result]\n${text}`; + }) + .join("\n\n"); + + const originalContent = item.userInputMessage.content || ""; + item.userInputMessage.content = originalContent + ? `${originalContent}\n\n${toolResultTexts}` + : toolResultTexts; + delete item.userInputMessage.userInputMessageContext.toolResults; + + if (Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { + delete item.userInputMessage.userInputMessageContext; + } + } + } + + // Also check currentMessage for orphaned toolResults (not in history) + if (currentMessage?.userInputMessage?.userInputMessageContext?.toolResults) { + const lastHistory = mergedHistory[mergedHistory.length - 1]; + const hasPrecedingAssistant = + lastHistory?.assistantResponseMessage?.toolUses && + lastHistory.assistantResponseMessage.toolUses.length > 0; + + if (!hasPrecedingAssistant) { + const toolResults = currentMessage.userInputMessage.userInputMessageContext + .toolResults as Array<{ toolUseId?: string; content?: Array<{ text?: string }> }>; + const toolResultTexts = toolResults + .map((tr) => { + const id = tr.toolUseId || ""; + const text = tr.content?.map((c) => c.text || "").join("\n") || ""; + return id ? `[Tool Result (${id})]\n${text}` : `[Tool Result]\n${text}`; + }) + .join("\n\n"); + + const originalContent = currentMessage.userInputMessage.content || ""; + currentMessage.userInputMessage.content = originalContent + ? `${originalContent}\n\n${toolResultTexts}` + : toolResultTexts; + delete currentMessage.userInputMessage.userInputMessageContext.toolResults; + + if (Object.keys(currentMessage.userInputMessage.userInputMessageContext).length === 0) { + delete currentMessage.userInputMessage.userInputMessageContext; + } + } + } + + // Ensure alternating roles by inserting synthetic assistant messages + // between consecutive user turns that couldn't be merged. + const alternatingHistory: typeof mergedHistory = []; + for (const item of mergedHistory) { + const last = alternatingHistory[alternatingHistory.length - 1]; + if (item.userInputMessage && last?.userInputMessage) { + alternatingHistory.push({ + assistantResponseMessage: { content: "(empty)" }, + }); + } + alternatingHistory.push(item); + } + + return { history: alternatingHistory, currentMessage }; } /** diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index adf7d46b37..02d62e3612 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -80,7 +80,11 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res assert.equal(result.conversationState.history.length, 2); assert.deepEqual(result.conversationState.history[0], { - userInputMessage: { content: "Rules\n\nHello", modelId: "claude-sonnet-4" }, + userInputMessage: { + content: "Rules\n\nHello", + modelId: "claude-sonnet-4", + origin: "AI_EDITOR", + }, }); assert.deepEqual(result.conversationState.history[1], { assistantResponseMessage: { @@ -111,7 +115,6 @@ test("OpenAI -> Kiro preserves prior history, tool uses and accumulated tool res assert.deepEqual(context.tools[0].toolSpecification.inputSchema.json, { type: "object", properties: { path: { type: "string" } }, - required: [], }); }); @@ -217,7 +220,9 @@ test("OpenAI -> Kiro uses Continue currentMessage when the request ends with ass /^\[Context: Current time is .*Z\]\n\nContinue$/ ); assert.deepEqual(result.conversationState.history, [ - { userInputMessage: { content: "First user", modelId: "claude-sonnet-4" } }, + { + userInputMessage: { content: "First user", modelId: "claude-sonnet-4", origin: "AI_EDITOR" }, + }, { assistantResponseMessage: { content: "Assistant answer" } }, ]); }); @@ -289,3 +294,143 @@ test("OpenAI -> Kiro merges adjacent user history turns after role normalization assert.equal(firstUser.content, "System rules\n\nFirst question"); assert.equal(history[1].assistantResponseMessage?.content, "Answer 1"); }); + +test("OpenAI -> Kiro strips additionalProperties and empty required from tool schemas", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [{ role: "user", content: "Hi" }], + tools: [ + { + type: "function", + function: { + name: "test_tool", + description: "Test", + parameters: { + type: "object", + properties: { + path: { type: "string", additionalProperties: false }, + nested: { + type: "object", + properties: { id: { type: "string" } }, + additionalProperties: true, + }, + }, + required: [], + additionalProperties: false, + }, + }, + }, + ], + }, + false, + null + ); + + const schema = result.conversationState.currentMessage.userInputMessage.userInputMessageContext + ?.tools?.[0]?.toolSpecification?.inputSchema?.json as any; + + assert.ok(schema, "schema should exist"); + assert.equal( + schema.additionalProperties, + undefined, + "top-level additionalProperties should be stripped" + ); + assert.equal(schema.required, undefined, "empty required should be omitted"); + assert.equal( + schema.properties.path.additionalProperties, + undefined, + "nested additionalProperties should be stripped" + ); + assert.equal( + schema.properties.nested.additionalProperties, + undefined, + "deep nested additionalProperties should be stripped" + ); +}); + +test("OpenAI -> Kiro merges consecutive assistant messages", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Part 1" }, + { role: "assistant", content: "Part 2" }, + { role: "user", content: "Continue" }, + ], + }, + false, + null + ); + + const history = result.conversationState.history as any[]; + assert.equal(history.length, 2, "consecutive assistants should be merged into one"); + assert.equal(history[0].userInputMessage.content, "Hello"); + assert.equal(history[1].assistantResponseMessage.content, "Part 1\n\nPart 2"); +}); + +test("OpenAI -> Kiro prepends synthetic user when conversation starts with assistant", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "assistant", content: "Greeting" }, + { role: "user", content: "Hello" }, + ], + }, + false, + null + ); + + const history = result.conversationState.history as any[]; + assert.equal(history.length, 2); + assert.equal(history[0].userInputMessage.content, "(empty)"); + assert.equal(history[0].userInputMessage.origin, "AI_EDITOR"); + assert.equal(history[1].assistantResponseMessage.content, "Greeting"); +}); + +test("OpenAI -> Kiro converts orphaned tool results to text", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "First" }, + { role: "assistant", content: "Answer" }, + { role: "tool", tool_call_id: "orphan_1", content: "result data" }, + { role: "user", content: "Follow-up" }, + ], + }, + false, + null + ); + + const currentMsg = result.conversationState.currentMessage.userInputMessage; + assert.match(currentMsg.content, /Follow-up\n\n\[Tool Result \(orphan_1\)\]\nresult data$/); + assert.equal( + currentMsg.userInputMessageContext, + undefined, + "orphaned toolResults should be removed from context" + ); +}); + +test("OpenAI -> Kiro includes origin on all history user messages", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "user", content: "A" }, + { role: "assistant", content: "B" }, + { role: "user", content: "C" }, + ], + }, + false, + null + ); + + const history = result.conversationState.history as any[]; + assert.equal(history[0].userInputMessage.origin, "AI_EDITOR"); + assert.equal(history[1].assistantResponseMessage.content, "B"); + // Note: last user message becomes currentMessage, not history + assert.equal(history.length, 2); +});