From 149d13cb9c846d2e67030d3b8dc15d7d734559d9 Mon Sep 17 00:00:00 2001 From: Gioxa Date: Sun, 10 May 2026 10:00:08 +0700 Subject: [PATCH] fix(kiro): merge adjacent user history turns after role normalization (#2105) Merged automatically --- open-sse/translator/request/openai-to-kiro.ts | 45 ++++++++++++++++++- tests/unit/translator-openai-to-kiro.test.ts | 35 +++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 02910873d2..1eb87198dc 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -288,7 +288,50 @@ function convertMessages(messages, tools, model) { } }); - return { history, currentMessage }; + // Kiro expects history to alternate between user and assistant turns. After + // normalizing `system`/`tool` roles into `userInputMessage`, the history can + // contain adjacent user turns, which Kiro can reject. Merge consecutive + // `userInputMessage` entries by concatenating their content and preserving + // any attached `userInputMessageContext` (e.g. accumulated toolResults). + // + // Why this is not redundant with the `flushPending` grouping in the main + // loop: the assistant branch resets `currentRole = null` after emitting + // `toolUses`. Any following `tool` role (normalized to user) and a + // subsequent `user` role therefore each open their own flush, producing + // two adjacent `userInputMessage` entries in history. This pass collapses + // those. + const mergedHistory: typeof history = []; + for (const item of history) { + const previous = mergedHistory[mergedHistory.length - 1]; + if (item.userInputMessage && previous?.userInputMessage) { + const previousContent = previous.userInputMessage.content || ""; + const currentContent = item.userInputMessage.content || ""; + previous.userInputMessage.content = previousContent + ? `${previousContent}\n\n${currentContent}` + : currentContent; + + if (item.userInputMessage.userInputMessageContext) { + const previousContext = previous.userInputMessage.userInputMessageContext || {}; + const nextContext = item.userInputMessage.userInputMessageContext; + const mergedContext: Record = { ...previousContext }; + + for (const [key, value] of Object.entries(nextContext)) { + const existing = (previousContext as Record)[key]; + if (Array.isArray(existing) && Array.isArray(value)) { + mergedContext[key] = [...existing, ...value]; + } else { + mergedContext[key] = value; + } + } + + previous.userInputMessage.userInputMessageContext = mergedContext; + } + } else { + mergedHistory.push(item); + } + } + + return { history: mergedHistory, currentMessage }; } /** diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 0ffc95771e..adf7d46b37 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -254,3 +254,38 @@ test("OpenAI -> Kiro still returns a valid payload for minimal requests", () => ); assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "claude-sonnet-4"); }); + +test("OpenAI -> Kiro merges adjacent user history turns after role normalization", () => { + const result = buildKiroPayload( + "claude-sonnet-4", + { + messages: [ + { role: "system", content: "System rules" }, + { role: "user", content: "First question" }, + { role: "assistant", content: "Answer 1" }, + { role: "tool", tool_call_id: "call_orphan", content: "tool log" }, + { role: "user", content: "Follow-up" }, + ], + }, + false, + null + ); + + const history = result.conversationState.history as Array<{ + userInputMessage?: { content: string }; + assistantResponseMessage?: { content: string }; + }>; + + for (let i = 1; i < history.length; i++) { + assert.equal( + Boolean(history[i - 1].userInputMessage) && Boolean(history[i].userInputMessage), + false, + "history should not contain adjacent userInputMessage turns" + ); + } + + const firstUser = history[0].userInputMessage; + assert.ok(firstUser, "first history turn should be a user turn"); + assert.equal(firstUser.content, "System rules\n\nFirst question"); + assert.equal(history[1].assistantResponseMessage?.content, "Answer 1"); +});