fix(kiro): harden OpenAI-to-Kiro translator for API compliance

- Normalize tool schemas: strip additionalProperties and empty required arrays
- Merge consecutive assistant messages and adjacent user turns after role normalization
- Prepend synthetic user message when conversation starts with assistant
- Convert orphaned toolResults to inline text when assistant with toolUses is missing
- Enforce strictly alternating user/assistant roles in history
- Use deterministic uuidv5 conversationId based on first message for session caching
- Ensure origin field is present on all userInputMessage entries
This commit is contained in:
8mbe
2026-05-14 15:33:19 +03:00
parent 41946c44c9
commit 26758b3ed9
2 changed files with 311 additions and 16 deletions

View File

@@ -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<string, unknown> {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
return { type: "object", properties: {}, required: [] };
return { type: "object", properties: {} };
}
return {
type: "object",
properties: {},
...(schema as Record<string, unknown>),
required: Array.isArray((schema as { required?: unknown }).required)
? (schema as { required: unknown[] }).required
: [],
};
const result: Record<string, unknown> = {};
const src = schema as Record<string, unknown>;
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<string, unknown> = {};
for (const [propName, propValue] of Object.entries(value as Record<string, unknown>)) {
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<Record<string, unknown>>;
tools?: Array<Record<string, unknown>>;
@@ -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 };
}
/**

View File

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