From 82b1bca42a12fb50b19643d2ae73b2d99d80233e Mon Sep 17 00:00:00 2001 From: Qisthi Ramadhani Date: Thu, 17 Sep 2026 18:12:34 +0700 Subject: [PATCH] fix(kiro): use neutral placeholder for tool-result-only user turns Replace the literal 'continue' placeholder on tool-result-only user turns with 'Tool results provided.' to prevent models from treating it as a new user instruction. --- .../translator/concerns/kiroConversation.js | 17 ++- open-sse/translator/request/claude-to-kiro.js | 4 +- open-sse/translator/request/openai-to-kiro.js | 4 +- .../__snapshots__/golden-request.test.js.snap | 8 +- .../unit/kiro-tool-result-placeholder.test.js | 115 ++++++++++++++++++ 5 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 tests/unit/kiro-tool-result-placeholder.test.js diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js index 11d49dc7..5ac1485b 100644 --- a/open-sse/translator/concerns/kiroConversation.js +++ b/open-sse/translator/concerns/kiroConversation.js @@ -5,6 +5,20 @@ import { } from "../../config/kiroConstants.js"; const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + +/** + * Kiro rejects user turns with empty `content`, so a turn that only carries + * tool results needs placeholder text. It must not read like a user + * instruction: with "continue", models answer the word itself ("Nothing in + * progress to continue") and drop the task they were in the middle of. + */ +export const KIRO_TOOL_RESULTS_PLACEHOLDER = "Tool results provided."; +export const KIRO_EMPTY_USER_PLACEHOLDER = "continue"; + +/** Placeholder content for a user turn with no text of its own. */ +export function kiroEmptyUserContent(hasToolResults) { + return hasToolResults ? KIRO_TOOL_RESULTS_PLACEHOLDER : KIRO_EMPTY_USER_PLACEHOLDER; +} const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g; function clone(value) { @@ -174,7 +188,8 @@ function normalizeTurns(history, currentMessage, modelId) { for (const turn of turns) { if (turn.userInputMessage) { - turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue"; + turn.userInputMessage.content = text(turn.userInputMessage.content).trim() + || kiroEmptyUserContent(turn.userInputMessage.userInputMessageContext?.toolResults?.length > 0); turn.userInputMessage.modelId ||= modelId; if (turn.userInputMessage.userInputMessageContext?.tools) { delete turn.userInputMessage.userInputMessageContext.tools; diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index ac7a704d..cffe59ec 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -34,6 +34,7 @@ import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; import { canonicalizeKiroConversation, normalizeKiroToolSpecs, + kiroEmptyUserContent, } from "../concerns/kiroConversation.js"; /** @@ -53,7 +54,8 @@ function convertClaudeMessagesToKiro(messages, model) { const flushPending = () => { if (currentRole === ROLE.USER) { - const content = pendingUserContent.join("\n\n").trim() || "continue"; + const content = pendingUserContent.join("\n\n").trim() + || kiroEmptyUserContent(pendingToolResults.length > 0); const userMsg = { userInputMessage: { content, modelId: model } }; if (pendingImages.length > 0) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 0c9f04fd..b8846660 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -23,6 +23,7 @@ import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; import { canonicalizeKiroConversation, normalizeKiroToolSpecs, + kiroEmptyUserContent, } from "../concerns/kiroConversation.js"; /** @@ -51,7 +52,8 @@ function convertMessages(messages, model) { const flushPending = () => { if (currentRole === "user") { - const content = pendingUserContent.join("\n\n").trim() || "continue"; + const content = pendingUserContent.join("\n\n").trim() + || kiroEmptyUserContent(pendingToolResults.length > 0); const userMsg = { userInputMessage: { content: content, diff --git a/tests/translator/__snapshots__/golden-request.test.js.snap b/tests/translator/__snapshots__/golden-request.test.js.snap index a7a0c219..f5a5253b 100644 --- a/tests/translator/__snapshots__/golden-request.test.js.snap +++ b/tests/translator/__snapshots__/golden-request.test.js.snap @@ -239,7 +239,7 @@ exports[`GOLDEN request: OpenAI → Kiro > full body (image base64 + tool_result "userInputMessage": { "content": "[Context: Current time is -continue", +Tool results provided.", "modelId": "claude-sonnet-4.5", "origin": "AI_EDITOR", "userInputMessageContext": { @@ -281,7 +281,11 @@ continue", "history": [ { "userInputMessage": { - "content": "You are helpful. + "content": "[Context: Current time is + + +You are helpful. + What's in this image?", "images": [ diff --git a/tests/unit/kiro-tool-result-placeholder.test.js b/tests/unit/kiro-tool-result-placeholder.test.js new file mode 100644 index 00000000..ab910b42 --- /dev/null +++ b/tests/unit/kiro-tool-result-placeholder.test.js @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { + canonicalizeKiroConversation, + KIRO_TOOL_RESULTS_PLACEHOLDER, + KIRO_EMPTY_USER_PLACEHOLDER, +} from "../../open-sse/translator/concerns/kiroConversation.js"; + +const TOOLS_OPENAI = [{ + type: "function", + function: { + name: "get_weather", + description: "Get weather", + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, + }, +}]; + +const TOOLS_CLAUDE = [{ + name: "get_weather", + description: "Get weather", + input_schema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, +}]; + +function allUserContents(payload) { + const state = payload.conversationState; + return [ + ...state.history.filter((t) => t.userInputMessage).map((t) => t.userInputMessage.content), + state.currentMessage.userInputMessage.content, + ]; +} + +describe("Kiro tool-result-only turns", () => { + it("OpenAI → Kiro: tool message gets a neutral placeholder, not \"continue\"", () => { + const payload = openaiToKiroRequest("claude-sonnet-4.6", { + tools: TOOLS_OPENAI, + messages: [ + { role: "user", content: "The secret word is PINEAPPLE. Weather in Jakarta?" }, + { role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather", arguments: "{\"city\":\"Jakarta\"}" } }] }, + { role: "tool", tool_call_id: "call_1", content: "32C, humid" }, + ], + }, true, {}); + + const current = payload.conversationState.currentMessage.userInputMessage; + expect(current.content).toContain(KIRO_TOOL_RESULTS_PLACEHOLDER); + expect(current.content).not.toMatch(/\bcontinue\b/); + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + expect(allUserContents(payload).join("\n")).toContain("PINEAPPLE"); + }); + + it("Claude → Kiro: tool_result-only user message gets a neutral placeholder", () => { + const payload = claudeToKiroRequest("claude-sonnet-4.6", { + tools: TOOLS_CLAUDE, + messages: [ + { role: "user", content: "The secret word is PINEAPPLE. Weather in Jakarta?" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Jakarta" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "32C, humid" }] }, + ], + }, true, {}); + + const current = payload.conversationState.currentMessage.userInputMessage; + expect(current.content).toContain(KIRO_TOOL_RESULTS_PLACEHOLDER); + expect(current.content).not.toMatch(/\bcontinue\b/); + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + }); + + it("keeps real user text when a turn has both text and tool results", () => { + const payload = claudeToKiroRequest("claude-sonnet-4.6", { + tools: TOOLS_CLAUDE, + messages: [ + { role: "user", content: "Weather in Jakarta?" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "get_weather", input: { city: "Jakarta" } }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_1", content: "32C" }, + { type: "text", text: "Now answer in one word." }, + ] }, + ], + }, true, {}); + + const current = payload.conversationState.currentMessage.userInputMessage; + expect(current.content).toContain("Now answer in one word."); + expect(current.content).not.toContain(KIRO_TOOL_RESULTS_PLACEHOLDER); + }); + + it("canonicalize: history turn with tool results and no text uses the placeholder", () => { + const result = canonicalizeKiroConversation({ + history: [ + { userInputMessage: { content: "Weather in Jakarta?", modelId: "m" } }, + { assistantResponseMessage: { content: "", toolUses: [{ toolUseId: "t1", name: "get_weather", input: { city: "Jakarta" } }] } }, + { userInputMessage: { content: "", modelId: "m", userInputMessageContext: { toolResults: [{ toolUseId: "t1", status: "success", content: [{ text: "32C" }] }] } } }, + { assistantResponseMessage: { content: "It is 32C." } }, + ], + currentMessage: { userInputMessage: { content: "Hot or cold?", modelId: "m" } }, + modelId: "m", + toolSpecs: [{ toolSpecification: { name: "get_weather", description: "Get weather", inputSchema: { json: { type: "object", properties: {} } } } }], + nameMap: new Map([["get_weather", "get_weather"]]), + }); + + expect(result.valid).toBe(true); + expect(result.history[2].userInputMessage.content).toBe(KIRO_TOOL_RESULTS_PLACEHOLDER); + }); + + it("canonicalize: an empty turn without tool results still falls back to \"continue\"", () => { + const result = canonicalizeKiroConversation({ + history: [{ assistantResponseMessage: { content: "Hello" } }], + currentMessage: { userInputMessage: { content: "", modelId: "m" } }, + modelId: "m", + toolSpecs: [], + nameMap: new Map(), + }); + + expect(result.history[0].userInputMessage.content).toBe(KIRO_EMPTY_USER_PLACEHOLDER); + expect(result.currentMessage.userInputMessage.content).toBe(KIRO_EMPTY_USER_PLACEHOLDER); + }); +});