Merge PR #2251: fix(kiro): harden OpenAI-to-Kiro translator for API compliance (thanks @8mbe)

Closes #2213

Conflict resolution: kept both images field (HEAD) and origin field (PR),
kept toolsAttached return value (HEAD) while incorporating all 8mbe
improvements (schema normalization, synthetic user guard, orphaned tool
results, alternating role enforcement). All 16 tests pass.
This commit is contained in:
diegosouzapw
2026-05-14 13:45:24 -03:00
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;
}
/**
@@ -59,12 +95,13 @@ 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;
images?: Array<{ format: string; source: { bytes: string } }>;
origin: string;
userInputMessageContext?: {
toolResults?: Array<Record<string, unknown>>;
tools?: Array<Record<string, unknown>>;
@@ -75,6 +112,7 @@ function convertMessages(messages, tools, model) {
userInputMessage: {
content: content,
modelId: "",
origin: "AI_EDITOR",
},
};
@@ -140,7 +178,7 @@ function convertMessages(messages, tools, model) {
pendingToolResults = [];
pendingImages = [];
} else if (currentRole === "assistant") {
const content = pendingAssistantContent.join("\n\n").trim() || "...";
const content = pendingAssistantContent.join("\n\n").trim() || "(empty)";
const assistantMsg = {
assistantResponseMessage: {
content: content,
@@ -367,6 +405,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
@@ -407,12 +450,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, toolsAttached };
// 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, toolsAttached };
}
/**

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" } },
]);
});
@@ -439,3 +444,143 @@ test("OpenAI -> Kiro attaches tools to currentMessage when history has no user t
assert.equal(ctx.tools!.length, 1);
assert.equal(ctx.tools![0].toolSpecification.name, "edit");
});
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);
});