Merge pull request #9067 from Kilo-Org/fix/queued-prompt-reorder

fix(cli): move queued user prompts to end of history
This commit is contained in:
Christiaan Arnoldus
2026-04-17 13:55:38 +02:00
committed by GitHub
3 changed files with 92 additions and 1 deletions
+6
View File
@@ -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.
@@ -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<A, E>(
@@ -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<void>()
const release = Promise.withResolvers<void>()