From 90aca8b85e66ecd1dcf6e887a5ead6ad02f339a2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:31:12 -0300 Subject: [PATCH] fix(translator): wrap Kiro system prompt in (port from 9router#2306) (#6053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro/CodeWhisperer has no system role, so system messages were normalized to a user turn with no wrapper β€” the full Claude Code system prompt then appeared as raw user text, polluting the model context. Wrap system-origin content in tags before merging it into the Kiro user message. Real user turns are unaffected. Existing history-merge tests aligned to the wrapped value. Reported-by: VitzS7 (https://github.com/decolua/9router/issues/2306) --- CHANGELOG.md | 2 ++ open-sse/translator/request/openai-to-kiro.ts | 15 +++++++- tests/unit/kiro-system-reminder-2306.test.ts | 35 +++++++++++++++++++ tests/unit/translator-openai-to-kiro.test.ts | 13 ++++--- 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 tests/unit/kiro-system-reminder-2306.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8069afabe7..efc5455064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ - **dashboard ("Update now" β†’ Internal Server Error):** clicking **Update now** on the dashboard home could crash the page with a blank "Internal Server Error" screen (`Minified React error #31`). The handler POSTs the loopback-only `/api/system/version` auto-update endpoint and, on a non-OK JSON response (e.g. a `403` when the dashboard is reached through a reverse proxy / non-loopback origin), passed the raw error envelope object `{ error: { code, message, correlation_id } }` straight to `notify.error()`, which rendered the object as a React child and threw #31. The update-error path now funnels the body through `extractApiErrorMessage()` (the same safe extractor added in #5340), so a readable string always reaches the toast. Regression guard: `tests/unit/ui/home-update-error-render-5991.test.ts`. ([#5991](https://github.com/diegosouzapw/OmniRoute/issues/5991)) +- **kiro (system prompt leaked as raw user text):** when Claude Code routed through the Kiro/CodeWhisperer backend, the `system` message was normalized to a `user` turn with no wrapper, so the entire system prompt (environment info, tool definitions, memory instructions, etc.) appeared as if the user had typed it β€” polluting the model context. System-origin content is now wrapped in `` tags before being merged into the Kiro user message, so the model can distinguish it from real user input. Real user turns are untouched. Regression guard: `tests/unit/kiro-system-reminder-2306.test.ts`. (thanks @VitzS7) + ### πŸ“ Maintenance - **test (deflake `setup-claude`):** `tests/unit/cli/setup-claude.test.ts` failed ~50% of runs with `Unable to deserialize cloned data due to invalid or unsupported version` at file teardown (all subtests passed), randomly reddening `Unit Tests fast-path (2/2)` / `Fast Quality Gates` across the PRβ†’release queue. Root cause: `node --test` streams each file's report to the parent as V8-serialized frames on fd 1 (stdout), and the CLI helper under test (`syncClaudeProfilesFromModels`) prints progress via `console.log` β€” that stdout output interleaved with the serialized frames and corrupted the stream. The test now silences the stdout-writing `console` methods for the file's duration (no assertion inspects stdout), making it deterministic (15/15 green locally). ([#5959](https://github.com/diegosouzapw/OmniRoute/issues/5959)) diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index 1ae0bc1240..fc6d39abfd 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -32,6 +32,15 @@ export function hasUnsupportedKiroContextSuffix(model: unknown): boolean { ); } +/** + * Wrap system-prompt content in tags before it is merged into + * a Kiro user message. Kiro/CodeWhisperer has no `system` role, so without this + * the system prompt would appear as raw user text (issue #2306). + */ +function wrapSystemReminder(text: string): string { + return `\n${text}\n`; +} + /** * Convert OpenAI messages to Kiro format * Rules: system/tool/user -> user role, merge consecutive same roles @@ -238,7 +247,11 @@ function convertMessages(messages, tools, model) { content: [{ text: toolContent }], }); } else if (content) { - pendingUserContent.push(content); + // #2306: Kiro/CodeWhisperer has no `system` role, so system messages are + // normalized to `user`. Wrap their content in tags so + // the model can tell the system prompt apart from real user input instead + // of treating the full Claude Code prompt as something the user typed. + pendingUserContent.push(msg.role === "system" ? wrapSystemReminder(content) : content); } } else if (role === "assistant") { // Extract text content and tool uses diff --git a/tests/unit/kiro-system-reminder-2306.test.ts b/tests/unit/kiro-system-reminder-2306.test.ts new file mode 100644 index 0000000000..db05743716 --- /dev/null +++ b/tests/unit/kiro-system-reminder-2306.test.ts @@ -0,0 +1,35 @@ +/** + * #2306 β€” When Claude Code routes through the Kiro/CodeWhisperer backend, the + * `system` message was normalized to `role: user` WITHOUT any wrapper, so the + * full system prompt (env info, tool defs, memory instructions, etc.) appeared + * as raw user text β€” indistinguishable from real user input, polluting context. + * + * Fix: wrap system-origin content in `...` + * before it is merged into the Kiro user message. Real user turns stay raw. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { buildKiroPayload } from "../../open-sse/translator/request/openai-to-kiro.ts"; + +test("#2306 system prompt is wrapped in for Kiro, not raw user text", () => { + const body = { + messages: [ + { role: "system", content: "You are Claude Code. ENV: cwd=/tmp. Secret: do not reveal." }, + { role: "user", content: "hello there" }, + ], + }; + + const payload = JSON.stringify(buildKiroPayload("claude-sonnet-4-5", body, false, {})); + + assert.ok(payload.includes(""), "system content must be wrapped"); + assert.ok(payload.includes("You are Claude Code"), "system text must still be present"); + // The real user turn must NOT be wrapped. + assert.ok(payload.includes("hello there"), "user text preserved"); +}); + +test("#2306 a plain user-only request is never wrapped in ", () => { + const body = { messages: [{ role: "user", content: "just a normal question" }] }; + const payload = JSON.stringify(buildKiroPayload("claude-sonnet-4-5", body, false, {})); + assert.ok(!payload.includes(""), "no system β†’ no wrapper"); +}); diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 9ef079d8a2..9d74a3e20e 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -81,7 +81,9 @@ 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", + // #2306: the system prompt ("Rules") is wrapped in before + // being merged into the Kiro user turn, instead of leaking as raw user text. + content: "\nRules\n\n\nHello", modelId: "claude-sonnet-4", origin: "AI_EDITOR", }, @@ -233,11 +235,11 @@ test("OpenAI -> Kiro derives a stable conversationId for the same first history assert.equal( (first.conversationState as any).history[0].userInputMessage.content, - "Rules\n\nHello" + "\nRules\n\n\nHello" ); assert.equal( (second as any).conversationState.history[0].userInputMessage.content, - "Rules\n\nHello" + "\nRules\n\n\nHello" ); assert.equal(first.conversationState.conversationId, second.conversationState.conversationId); }); @@ -291,7 +293,10 @@ test("OpenAI -> Kiro merges adjacent user history turns after role normalization 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( + firstUser.content, + "\nSystem rules\n\n\nFirst question" + ); assert.equal(history[1].assistantResponseMessage?.content, "Answer 1"); });