Skip to content

Commit 12dbec3

Browse files
committed
fix(kiro): stop proxy filler from preceding a mid-turn steer
Reported as issue #543: mid-turn steering messages typed in Claude Code while the agent works were ignored on kiro/claude-opus-5, though the reporter's own control showed them working on kiro/claude-opus-4.8 with the same proxy build. Claude Code delivers such a steer as text riding the same user turn as the pending tool_result. The Kiro adapter merges adjacent user content, and the tool-result branch pushed KIRO_TOOL_RESULT_CARRIER_MESSAGE first, so the assembled current message read: "The requested tool result is attached. <the human's actual instruction>" The carrier sentence exists to keep an otherwise EMPTY tool-result turn from having blank content. It was never meant as a prefix, and putting it ahead of the newest human intent buries that intent behind boilerplate. Tool results now push empty content, and the carrier is backfilled only for turns that still have no text. The backfill runs before the current turn is popped: doing it after would ship an empty current content, which validateKiroConversationState accepts because tool results count as payload, so the mistake would fail silently. This is a targeted mitigation, not a proven fix for #543. Request construction is otherwise the same for both models — verified by invoking buildKiroPayload directly for each — differing only in model identity and opus-5's native output_config.effort, so any remaining divergence is upstream. The new parity test pins that so a future model-conditional regression on this path surfaces here instead of in a user's session.
1 parent c7fe9e8 commit 12dbec3

2 files changed

Lines changed: 74 additions & 1 deletion

File tree

src/adapters/kiro.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,11 @@ export function buildKiroPayload(
498498
if (!priorCalls.has(toolUseId)) {
499499
throw new Error(`Kiro history contains an orphaned tool result for call ${JSON.stringify(tr.toolCallId)}`);
500500
}
501-
pushUser(KIRO_TOOL_RESULT_CARRIER_MESSAGE, images, [{
501+
// Carrier text is a placeholder for an OTHERWISE EMPTY tool-result turn, not a prefix.
502+
// Passing it here would push proxy filler AHEAD of a human instruction that Claude Code
503+
// sends in the same turn (mid-turn steering / queued_command, issue #543), burying the
504+
// newest user intent behind boilerplate. Backfill below only when nothing else speaks.
505+
pushUser("", images, [{
502506
content: [{ text: resultText }],
503507
status: tr.isError ? "error" : "success",
504508
toolUseId,
@@ -518,6 +522,16 @@ export function buildKiroPayload(
518522
});
519523
}
520524

525+
// Give tool-result turns a carrier sentence ONLY when they carry no other text. This runs
526+
// before the pop below so the current turn is covered too: skipping it there would ship an
527+
// empty current content, which validateKiroConversationState accepts (tool results count as
528+
// payload) and would therefore fail silently.
529+
for (const turn of turns) {
530+
if (turn.kind === "user" && !turn.content.trim() && turn.toolResults.length > 0) {
531+
turn.content = KIRO_TOOL_RESULT_CARRIER_MESSAGE;
532+
}
533+
}
534+
521535
const currentTurn = turns.pop();
522536
if (!currentTurn || currentTurn.kind !== "user") throw new Error("Kiro request must end with a user turn");
523537
const toEntry = (turn: KiroTurn): KiroHistoryEntry => turn.kind === "assistant"

tests/kiro-adapter.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,65 @@ describe("kiro adapter — native and emulated reasoning effort", () => {
840840
expect(content).not.toContain("<thinking_mode>");
841841
});
842842

843+
// issue #543: Claude Code sends a mid-turn steer (queued_command) as text riding the same
844+
// user turn as the pending tool_result. Proxy filler must never precede that instruction.
845+
test("a mid-turn steering message is the current turn without proxy carrier filler", async () => {
846+
const steer = "STOP editing module A. Use kiro/gpt-5.6-sol instead.";
847+
const messages = [
848+
{ role: "user", content: "Refactor module A." },
849+
{ role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "ls" } }] },
850+
{ role: "toolResult", toolCallId: "call-1", toolName: "bash", content: "file list", isError: false },
851+
{ role: "user", content: steer },
852+
];
853+
const { body } = await createKiroAdapter(provider).buildRequest(parsedWith(messages, [bashTool]));
854+
const current = JSON.parse(body).conversationState.currentMessage.userInputMessage;
855+
856+
// The human instruction is the whole content: the carrier sentence must be ABSENT, not
857+
// merely moved after it (a startsWith assertion would pass with filler appended).
858+
expect(current.content).toBe(steer);
859+
expect(current.content).not.toContain(KIRO_TOOL_RESULT_CARRIER_MESSAGE);
860+
// The tool result still rides along structurally, so no information is lost.
861+
expect(current.userInputMessageContext.toolResults).toEqual([
862+
{ content: [{ text: "file list" }], status: "success", toolUseId: "call-1" },
863+
]);
864+
});
865+
866+
test("mid-turn steering reaches Kiro identically for opus-5 and opus-4.8", async () => {
867+
// The #543 reporter observed opus-4.8 honoring mid-turn steers while opus-5 ignored them on
868+
// the same proxy build. Pin that our request construction does not differ between the two
869+
// beyond model identity and opus-5's native effort field, so a future model-conditional
870+
// regression on this path is caught here rather than in a user's session.
871+
const steer = "Stop and switch approach now.";
872+
const messages = [
873+
{ role: "user", content: "Start the task." },
874+
{ role: "assistant", content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "ls" } }] },
875+
{ role: "toolResult", toolCallId: "call-1", toolName: "bash", content: "out", isError: false },
876+
{ role: "user", content: steer },
877+
];
878+
const build = async (modelId: string) => {
879+
const { body } = await createKiroAdapter(provider).buildRequest({
880+
...parsedWith(messages, [bashTool], modelId),
881+
options: { reasoning: "high" },
882+
});
883+
return JSON.parse(body);
884+
};
885+
const opus5 = await build("claude-opus-5");
886+
const opus48 = await build("claude-opus-4.8");
887+
888+
for (const payload of [opus5, opus48]) {
889+
const current = payload.conversationState.currentMessage.userInputMessage;
890+
expect(current.content).toBe(steer);
891+
expect(current.userInputMessageContext.toolResults).toEqual([
892+
{ content: [{ text: "out" }], status: "success", toolUseId: "call-1" },
893+
]);
894+
}
895+
// Only the native-effort field may differ; opus-4.8 also gets no emulated thinking tags
896+
// here because tool-result turns skip that injection.
897+
expect(opus5.additionalModelRequestFields).toEqual({ output_config: { effort: "high" } });
898+
expect(opus48.additionalModelRequestFields).toBeUndefined();
899+
expect(opus48.conversationState.currentMessage.userInputMessage.content).not.toContain("<thinking_mode>");
900+
});
901+
843902
test("gpt-5.6-sol sends native reasoning while legacy models keep labeled emulation", async () => {
844903
const nativeBody = JSON.parse((await createKiroAdapter(provider).buildRequest({
845904
...parsedWith([{ role: "user", content: "solve" }], undefined, "gpt-5.6-sol"),

0 commit comments

Comments
 (0)