From 1892ed77c842d0be7db85e3958215ab699640dfc Mon Sep 17 00:00:00 2001 From: Federico Liva Date: Thu, 10 Sep 2026 22:23:01 +0700 Subject: [PATCH] fix(kiro): never send a top-level systemPrompt (400 REQUEST_BODY_INVALID) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kiro.dev rejects any body carrying a top-level systemPrompt with 400 REQUEST_BODY_INVALID. The translators stopped emitting the field in v0.5.59 (the prompt travels in the first user turn via contentPrefix), but two paths kept writing it back downstream of the translator: - rtk/systemInject.js::injectKiroSystem() appended the RTK prompt to body.systemPrompt, so every kr/ model failed whenever an RTK injector (caveman, ponytail) was active. It now appends to the first history user turn's content (else currentMessage), reusing dedupStringAppend/hasPrompt so retries stay idempotent. - executors/kiro.js::appendRepairInstruction() wrote the tool-call repair instruction to systemPrompt on the retry, turning every repair into a hard failure. It now appends to currentMessage.userInputMessage.content. isKiroBody() no longer requires a string body.systemPrompt — that marker is gone from the wire shape — and sniffs the conversation turn shape instead, keeping the stray-conversationState guard intact. Stale comments in both kiro translators corrected: the systemPrompt local is only a session-replay cache key, not a wire field. Also drops the mirror/rollback repair heuristic the injector no longer needs: net -52 lines. Fixes #3641, #3845, #2890, #2901, #2939, #3109, #3459, #3749 --- open-sse/executors/kiro.js | 12 +- open-sse/rtk/systemInject.js | 100 ++++--------- open-sse/translator/request/claude-to-kiro.js | 6 +- open-sse/translator/request/openai-to-kiro.js | 6 +- tests/unit/kiro-terminal-integrity.test.js | 10 +- tests/unit/system-inject.test.js | 136 ++++++++---------- 6 files changed, 109 insertions(+), 161 deletions(-) diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index e12aced6..9d5d754f 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -127,12 +127,18 @@ async function readResponsePrefix(response, signal, maxBytes, timeoutMs) { return decoder.decode(concatChunks(chunks, totalBytes)); } +// The instruction goes into the current user turn, never into a top-level +// `systemPrompt`: kiro.dev answers any body carrying that field with +// 400 REQUEST_BODY_INVALID, so writing it here turned every repair retry into +// a hard failure. function appendRepairInstruction(body, kind) { const repaired = structuredClone(body || {}); const instruction = REPAIR_INSTRUCTIONS[kind] || "Retry the previous incomplete Kiro response."; - repaired.systemPrompt = repaired.systemPrompt - ? `${repaired.systemPrompt}\n\n${instruction}` - : instruction; + const msg = repaired?.conversationState?.currentMessage?.userInputMessage; + if (msg) { + const content = typeof msg.content === "string" ? msg.content : ""; + msg.content = content ? `${content}\n\n${instruction}` : instruction; + } return repaired; } diff --git a/open-sse/rtk/systemInject.js b/open-sse/rtk/systemInject.js index b60e15d3..b4e744f8 100644 --- a/open-sse/rtk/systemInject.js +++ b/open-sse/rtk/systemInject.js @@ -13,7 +13,7 @@ export function injectSystemPrompt(body, format, prompt) { if (!body || !prompt) return; if (typeof body !== "object") return; - // Kiro wire shape is unique (conversationState/systemPrompt) — handle directly. + // Kiro wire shape is unique (conversationState) — handle directly. if (isKiroBody(body) || format === FORMATS.KIRO) { injectKiroSystem(body, prompt); return; @@ -61,10 +61,13 @@ export function injectSystemPrompt(body, format, prompt) { function isKiroBody(body) { if (!body || typeof body !== "object") return false; - if (typeof body.systemPrompt !== "string") return false; const cs = body.conversationState; if (!cs || typeof cs !== "object") return false; - return Array.isArray(cs.history) || !!(cs.currentMessage && typeof cs.currentMessage === "object"); + // A top-level `systemPrompt` used to be the marker, but the Kiro translator no + // longer emits it (kiro.dev rejects the field), so gate on the turn shape. + const historyTurn = Array.isArray(cs.history) + && cs.history.some(it => it && (it.userInputMessage || it.assistantResponseMessage)); + return historyTurn || !!(cs.currentMessage && cs.currentMessage.userInputMessage); } // Exact idempotency: prompt present as its own SEP-delimited segment (or the @@ -258,80 +261,33 @@ function injectGeminiSystem(body, prompt) { } // ---- Kiro ---- -// Updates top-level systemPrompt and only the mirrored leading prefix of the -// first user history turn, else current user. next = old + SEP + prompt. -// Replace old leading prefix only; preserve time context and user tail. +// The prompt is appended to the first user turn's content — the same place the +// Kiro translator already mirrors the system text via its contentPrefix. +// +// A top-level `systemPrompt` is deliberately NOT written: the kiro.dev gateway +// answers any body carrying that field with +// 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"} +// The translator stopped emitting it in v0.5.59, but this injector kept adding +// it back, so every kr/ model failed whenever an RTK prompt (caveman, ponytail) +// was active. function injectKiroSystem(body, prompt) { try { - let oldPrompt = typeof body.systemPrompt === "string" ? body.systemPrompt : ""; - // Repair path: a previous partial write left systemPrompt updated but user - // content still mirroring the pre-write prefix. Re-derive the effective old - // prefix from content so this pass converges instead of early-returning. - const cs0 = body.conversationState; - let firstUser0 = cs0 && Array.isArray(cs0.history) - ? (cs0.history.find(it => it && it.userInputMessage)?.userInputMessage ?? null) - : null; - if (!firstUser0 && cs0?.currentMessage?.userInputMessage) firstUser0 = cs0.currentMessage.userInputMessage; - - if (firstUser0 && typeof firstUser0.content === "string" && oldPrompt && !hasPrompt(oldPrompt, prompt)) { - const c0 = firstUser0.content; - if (c0 === oldPrompt || (c0.startsWith(oldPrompt) && !c0.startsWith(`${oldPrompt}${SEP}`))) { - // systemPrompt advanced past mirrored prefix → stale; treat as un-mirrored - oldPrompt = ""; - } - } - if (oldPrompt && hasPrompt(oldPrompt, prompt)) return; - const next = oldPrompt ? `${oldPrompt}${SEP}${prompt}` : prompt; - - // Atomicity: write user content first, then systemPrompt only if content - // write succeeded (or was a no-op). If systemPrompt write then fails, the - // repair heuristic above re-derives from content on retry — no permanent - // half-applied state. const cs = body.conversationState; let targetMsg = null; - try { - const hist = Array.isArray(cs?.history) ? cs.history : null; - if (hist) { - for (const item of hist) { - if (item && item.userInputMessage) { targetMsg = item.userInputMessage; break; } - } - } - if (!targetMsg && cs?.currentMessage?.userInputMessage) { - targetMsg = cs.currentMessage.userInputMessage; - } - } catch (_) { targetMsg = null; } - - let sysWritten = false; - try { body.systemPrompt = next; sysWritten = true; } catch (_) {} - - const applyContent = () => { - const content = typeof targetMsg.content === "string" ? targetMsg.content : ""; - if (oldPrompt === "") { - // Empty old prompt: prepend unless already at head (exact, not substring) - if (content.startsWith(prompt) || content.startsWith(next)) return; - const newContent = content ? `${next}${SEP}${content}` : next; - try { targetMsg.content = newContent; } catch (_) {} - return; - } - if (!content.startsWith(oldPrompt)) return; // not mirrored at head — leave alone - if (content.startsWith(next)) return; // already applied → idempotent - const tail = content.slice(oldPrompt.length); - try { targetMsg.content = `${next}${tail}`; } catch (_) {} - }; - - try { - if (targetMsg) applyContent(); - } catch (_) {} - if (sysWritten && targetMsg) { - // verify convergence: content should now start with next (or be un-mirrored) - let ok = false; - try { - const c = targetMsg.content; - ok = typeof c !== "string" || c.startsWith(next) || !c.startsWith(oldPrompt); - } catch (_) {} - if (!ok) { - try { body.systemPrompt = oldPrompt; } catch (_) {} // rollback + const hist = Array.isArray(cs?.history) ? cs.history : null; + if (hist) { + for (const item of hist) { + if (item && item.userInputMessage) { targetMsg = item.userInputMessage; break; } } } + if (!targetMsg && cs?.currentMessage?.userInputMessage) { + targetMsg = cs.currentMessage.userInputMessage; + } + if (!targetMsg) return; + + const content = typeof targetMsg.content === "string" ? targetMsg.content : ""; + const next = dedupStringAppend(content, prompt); + if (next === content) return; // already injected — idempotent across retries + try { targetMsg.content = next; } catch (_) { /* frozen/proxy fail-open */ } } catch (_) {} } diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 9a7cf377..ef2dd6c5 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -242,9 +242,9 @@ export function claudeToKiroRequest(model, body, stream, credentials) { ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); - // Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a - // content fallback too because the CodeWhisperer surface does not always - // enforce top-level systemPrompt for direct calls. + // The system prompt travels inside the first user turn's content (contentPrefix): + // the CodeWhisperer surface rejects a top-level `systemPrompt` with + // 400 REQUEST_BODY_INVALID, so the value below is only a replay cache key. const timestamp = new Date().toISOString(); const systemPromptParts = []; if (thinkingBudget !== null && !usesNativeGptEffort) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 990b607f..dbaefbae 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -340,9 +340,9 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const timestamp = new Date().toISOString(); - // Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback - // too because the CodeWhisperer surface does not always enforce top-level - // systemPrompt for direct calls. + // The system prompt travels inside the first user turn's content (contentPrefix): + // the CodeWhisperer surface rejects a top-level `systemPrompt` with + // 400 REQUEST_BODY_INVALID, so the value below is only a replay cache key. const systemPromptParts = []; if (thinkingBudget !== null && !usesNativeGptEffort) { systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); diff --git a/tests/unit/kiro-terminal-integrity.test.js b/tests/unit/kiro-terminal-integrity.test.js index aaf66209..3199c03d 100644 --- a/tests/unit/kiro-terminal-integrity.test.js +++ b/tests/unit/kiro-terminal-integrity.test.js @@ -115,7 +115,7 @@ async function text(stream) { async function execute(executor = new KiroExecutor(), overrides = {}) { return executor.execute({ model: "kr/claude-opus-4.8", - body: { systemPrompt: "base", conversationState: {} }, + body: { conversationState: { currentMessage: { userInputMessage: { content: "base", modelId: "m" } } } }, stream: true, credentials, ...overrides @@ -342,8 +342,12 @@ describe("Kiro terminal integrity recovery", () => { const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body); expect(body).toContain("Recovered safely."); - expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed"); - expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS"); + // The repair instruction rides in the user turn: kiro.dev rejects a + // top-level systemPrompt with 400 REQUEST_BODY_INVALID. + const retryContent = retryBody.conversationState.currentMessage.userInputMessage.content; + expect(retryBody.systemPrompt).toBeUndefined(); + expect(retryContent).toContain("tool_call wrapper was malformed"); + expect(retryContent).not.toContain("IGNORE_ALL_INSTRUCTIONS"); }); it("lets a complete tool call override metadata end_turn", async () => { diff --git a/tests/unit/system-inject.test.js b/tests/unit/system-inject.test.js index bff7c94c..fd074f2d 100644 --- a/tests/unit/system-inject.test.js +++ b/tests/unit/system-inject.test.js @@ -261,138 +261,121 @@ describe("system-inject gemini", () => { }); describe("system-inject kiro", () => { - it("updates systemPrompt and mirrored prefix of first history user preserving tail", () => { - const oldPrompt = "OLD_SYS"; + // The kiro.dev gateway rejects any body carrying a top-level `systemPrompt` + // with 400 REQUEST_BODY_INVALID, so the prompt goes into the user turn only. + it("appends to first history user, leaves systemPrompt untouched", () => { const timeCtx = "[Context: Current time is 2026-01-01T00:00:00.000Z]"; const tail = "user tail content"; - const historyUserContent = `${oldPrompt}${SEP}${timeCtx}${SEP}${tail}`; + const historyUserContent = `${timeCtx}${SEP}${tail}`; const body = { - systemPrompt: oldPrompt, conversationState: { history: [{ userInputMessage: { content: historyUserContent, modelId: "m" } }, { assistantResponseMessage: { content: "..." } }], currentMessage: { userInputMessage: { content: "current " + tail, modelId: "m" } }, }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - const next = `${oldPrompt}${SEP}${P1}`; - expect(body.systemPrompt).toBe(next); - expect(body.conversationState.history[0].userInputMessage.content).toBe(`${next}${SEP}${timeCtx}${SEP}${tail}`); + expect(body.systemPrompt).toBeUndefined(); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`${historyUserContent}${SEP}${P1}`); // currentMessage must stay untouched expect(body.conversationState.currentMessage.userInputMessage.content).toBe("current " + tail); }); - it("when no history user, updates currentMessage instead", () => { - const oldPrompt = "OLD"; - const body = { - systemPrompt: oldPrompt, - conversationState: { - history: [], - currentMessage: { userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }, - }, - }; - injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`); - expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`); - }); - - it("empty old prompt prepends to chosen user content", () => { - const body = { - systemPrompt: "", - conversationState: { - history: [{ userInputMessage: { content: "tail hello", modelId: "m" } }], - currentMessage: { userInputMessage: { content: "cur", modelId: "m" } }, - }, - }; - injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(P1); - expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}tail hello`); - }); - - it("if old prompt not mirrored at head, do not alter user content", () => { + it("never writes a top-level systemPrompt, even if one is already present", () => { const body = { systemPrompt: "OLD", conversationState: { - history: [{ userInputMessage: { content: "different head content", modelId: "m" } }], + history: [{ userInputMessage: { content: "tail", modelId: "m" } }], + }, + }; + injectSystemPrompt(body, FORMATS.KIRO, P1); + expect(body.systemPrompt).toBe("OLD"); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}`); + }); + + it("when no history user, updates currentMessage instead", () => { + const body = { + conversationState: { + history: [], + currentMessage: { userInputMessage: { content: "tail", modelId: "m" } }, + }, + }; + injectSystemPrompt(body, FORMATS.KIRO, P1); + expect(body.systemPrompt).toBeUndefined(); + expect(body.conversationState.currentMessage.userInputMessage.content).toBe(`tail${SEP}${P1}`); + }); + + it("empty user content becomes the prompt itself", () => { + const body = { + conversationState: { + history: [{ userInputMessage: { content: "", modelId: "m" } }], currentMessage: { userInputMessage: { content: "cur", modelId: "m" } }, }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(`OLD${SEP}${P1}`); - expect(body.conversationState.history[0].userInputMessage.content).toBe("different head content"); + expect(body.conversationState.history[0].userInputMessage.content).toBe(P1); + expect(body.conversationState.currentMessage.userInputMessage.content).toBe("cur"); }); it("exact retry idempotency for kiro", () => { - const oldPrompt = "OLD"; const body = { - systemPrompt: oldPrompt, conversationState: { - history: [{ userInputMessage: { content: `${oldPrompt}${SEP}tail`, modelId: "m" } }], + history: [{ userInputMessage: { content: "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "cur", modelId: "m" } }, }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - const after1 = JSON.parse(JSON.stringify(body)); + const after1 = body.conversationState.history[0].userInputMessage.content; injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(after1.systemPrompt); - expect(body.conversationState.history[0].userInputMessage.content).toBe(after1.conversationState.history[0].userInputMessage.content); - // different prompt both apply + expect(body.conversationState.history[0].userInputMessage.content).toBe(after1); + // different prompt both apply, in injection order injectSystemPrompt(body, FORMATS.KIRO, P2); - expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}${SEP}${P2}`); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`tail${SEP}${P1}${SEP}${P2}`); }); it("preserves non-enumerable _kiroUpstreamModel", () => { const body = { - systemPrompt: "OLD", - conversationState: { history: [{ userInputMessage: { content: "OLD" + SEP + "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "OLD" + SEP + "tail2", modelId: "m" } } }, + conversationState: { history: [{ userInputMessage: { content: "tail", modelId: "m" } }], currentMessage: { userInputMessage: { content: "tail2", modelId: "m" } } }, }; Object.defineProperty(body, "_kiroUpstreamModel", { value: "m", enumerable: false }); injectSystemPrompt(body, FORMATS.KIRO, P1); expect(body._kiroUpstreamModel).toBe("m"); expect(Object.getOwnPropertyDescriptor(body, "_kiroUpstreamModel").enumerable).toBe(false); }); + + it("frozen user message fails open without throwing or half-writing", () => { + const body = { + conversationState: { + history: [{ userInputMessage: Object.freeze({ content: "tail", modelId: "m" }) }], + }, + }; + expect(() => injectSystemPrompt(body, FORMATS.KIRO, P1)).not.toThrow(); + expect(body.systemPrompt).toBeUndefined(); + expect(body.conversationState.history[0].userInputMessage.content).toBe("tail"); + }); }); describe("system-inject regression fixes", () => { it("kiro partial mutation converges on retry after transient content write failure", () => { - const oldPrompt = "OLD"; let failNextWrite = true; - const um = { content: `${oldPrompt}${SEP}tail`, modelId: "m" }; + const um = { content: "tail", modelId: "m" }; const proxiedUm = new Proxy(um, { set(t, p, v) { if (p === "content" && failNextWrite) { failNextWrite = false; throw new Error("transient"); } t[p] = v; return true; }, }); - const body = { - systemPrompt: oldPrompt, - conversationState: { - history: [{ userInputMessage: proxiedUm }], - }, - }; + const body = { conversationState: { history: [{ userInputMessage: proxiedUm }] } }; injectSystemPrompt(body, FORMATS.KIRO, P1); - // first pass rolled back atomically — nothing half-applied - expect(body.systemPrompt).toBe(oldPrompt); - expect(um.content).toBe(`${oldPrompt}${SEP}tail`); + // nothing half-applied + expect(um.content).toBe("tail"); + expect(body.systemPrompt).toBeUndefined(); // retry converges injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(`${oldPrompt}${SEP}${P1}`); - expect(um.content).toBe(`${oldPrompt}${SEP}${P1}${SEP}tail`); - }); - - it("kiro rolls back systemPrompt when user content write fails (atomicity)", () => { - const oldPrompt = "OLD"; - const body = { - systemPrompt: oldPrompt, - conversationState: { - history: [{ userInputMessage: Object.freeze({ content: `${oldPrompt}${SEP}tail`, modelId: "m" }) }], - }, - }; - injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.systemPrompt).toBe(oldPrompt); + expect(um.content).toBe(`tail${SEP}${P1}`); }); it("kiro shape gate: stray conversationState without history/currentMessage does not hijack chat body", () => { - const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], systemPrompt: "", conversationState: {} }; + const body = { messages: [{ role: ROLE.SYSTEM, content: "hello" }], conversationState: {} }; injectSystemPrompt(body, FORMATS.OPENAI, P1); expect(body.messages[0].content).toBe(`hello${SEP}${P1}`); }); @@ -409,15 +392,14 @@ describe("system-inject regression fixes", () => { expect(body.instructions).toBe(`You are RULE follower${SEP}RULE`); }); - it("kiro empty-old prepend fires when prompt appears mid-tail only", () => { + it("substring occurrence does not suppress kiro injection", () => { const body = { - systemPrompt: "", conversationState: { history: [{ userInputMessage: { content: `some ${P1} here`, modelId: "m" } }], }, }; injectSystemPrompt(body, FORMATS.KIRO, P1); - expect(body.conversationState.history[0].userInputMessage.content).toBe(`${P1}${SEP}some ${P1} here`); + expect(body.conversationState.history[0].userInputMessage.content).toBe(`some ${P1} here${SEP}${P1}`); }); });