From 959a8b498de6efd28756683162296dd40eb9b454 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:35:17 +0000 Subject: [PATCH] fix(cli): move queued user prompts to end of history When a user queued a prompt while the previous turn was still streaming, the message's time_created fell before later assistant steps of that turn. Ordering by time_created alone left the queued prompt in the middle of the prior turn's history, so the next request ended with an assistant message and tripped Anthropic's prefill rejection. Reorder inside KiloSessionPromptQueue.scope so the target user message and any of its own turn's assistants are always placed at the end. --- .changeset/fix-queued-prompt-reorder.md | 6 ++ .../src/kilocode/session/prompt-queue.ts | 20 +++++- .../kilocode/session-prompt-queue.test.ts | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-queued-prompt-reorder.md diff --git a/.changeset/fix-queued-prompt-reorder.md b/.changeset/fix-queued-prompt-reorder.md new file mode 100644 index 00000000000..79a373300fe --- /dev/null +++ b/.changeset/fix-queued-prompt-reorder.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt. diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 54adcb6c407..ab08c24346d 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -39,7 +39,25 @@ export namespace KiloSessionPromptQueue { if (item.info.role === "assistant") return !hidden.has(item.info.parentID) return true }) - return visible + + // When a user prompt is queued mid-turn, its time_created falls in the + // middle of the prior turn's messages (a later assistant step in that turn + // was written after the queue event). Ordering by time_created alone puts + // the queued prompt before the prior turn's final assistant reply, which + // makes the next request end with an assistant message and trips Anthropic's + // prefill rejection. Move the target user message and any of its own turn's + // assistant messages to the end so the request always ends with the queued + // user prompt (or with its own turn's latest assistant step). + const owns = (item: MessageV2.WithParts) => { + if (item.info.role === "user") return item.info.id === target + if (item.info.role === "assistant") return item.info.parentID === target + return false + } + const before: MessageV2.WithParts[] = [] + const after: MessageV2.WithParts[] = [] + for (const item of visible) (owns(item) ? after : before).push(item) + if (after.length === 0) return visible + return [...before, ...after] } export function enqueue( diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 39969b645dc..5d5e93ac1d0 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -121,6 +121,73 @@ describe("session prompt queue", () => { expect(ids).toEqual([one, ans, two]) }) + test("moves queued target to the end when prior-turn messages come after it", async () => { + // Regression: when a user queues a prompt while a turn is still running, + // the queued message's time_created falls before later assistant steps of + // that turn. Ordering by time_created alone would leave the queued prompt + // in the middle of the prior turn's messages, ending the next model request + // with an assistant message and tripping Anthropic's prefill rejection. + const sessionID = SessionID.make("session_queue_mid_turn") + const m1 = MessageID.make("message_10") + const a1 = MessageID.make("message_20") + const m2 = MessageID.make("message_30") + const a2step1 = MessageID.make("message_40") + const m3 = MessageID.make("message_50") // queued mid-turn + const a2step2 = MessageID.make("message_60") + const a2final = MessageID.make("message_70") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a2step1, m2), + user(sessionID, m3), + assistant(sessionID, a2step2, m2), + assistant(sessionID, a2final, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m3, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, m2, a2step1, a2step2, a2final, m3]) + expect(ids[ids.length - 1]).toBe(m3) + }) + + test("keeps the target turn's own assistant steps grouped at the end", async () => { + // After the first step of a queued turn has produced an assistant message, + // subsequent scope() calls should keep the target user together with its + // own turn's assistants (not interleaved with a prior turn's tail). + const sessionID = SessionID.make("session_queue_step_two") + const m1 = MessageID.make("message_01a") + const a1 = MessageID.make("message_02a") + const m2 = MessageID.make("message_03a") // queued mid-turn + const a1tail = MessageID.make("message_04a") + const a2step1 = MessageID.make("message_05a") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a1tail, m1), // prior turn's tail was written after m2 + assistant(sessionID, a2step1, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m2, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, a1tail, m2, a2step1]) + }) + test("continues a queued prompt after the active run finishes", async () => { const ready = Promise.withResolvers() const release = Promise.withResolvers()