fix(kiro): never send a top-level systemPrompt (400 REQUEST_BODY_INVALID)

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
This commit is contained in:
Federico Liva
2026-09-10 22:23:01 +07:00
committed by decolua
parent 1f10f9e5c4
commit 1892ed77c8
6 changed files with 109 additions and 161 deletions

View File

@@ -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 () => {

View File

@@ -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}`);
});
});