core: auto-dismiss suggestion and question prompts once a new user message is queued, so a queued follow-up runs immediately instead of stalling behind a tool waiting on esc

This commit is contained in:
Alex Alecu
2026-04-22 17:49:44 +03:00
parent 5221569844
commit 6d45e33efb
5 changed files with 286 additions and 1 deletions
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue"
import { Instance } from "../../src/project/instance"
import { Question } from "../../src/question"
import { SessionID } from "../../src/session/schema"
import { MessageID, SessionID } from "../../src/session/schema"
import { tmpdir } from "../fixture/fixture"
describe("Question.dismissAll", () => {
@@ -105,4 +107,68 @@ describe("Question.dismissAll", () => {
},
})
})
test("ask rejects immediately when a followup is queued on the session", async () => {
// When a newer prompt has already been enqueued on the session, a tool
// that subsequently calls Question.ask would otherwise block the run until
// the user manually dismisses it. Verify the pre-emptive hasFollowup check
// rejects with RejectedError before any pending entry is registered.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_ask")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
// Slot 1 stays running so activeSince is pinned to its seq.
const first = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_ask_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
),
)
await started.promise
// Slot 2 arrives while slot 1 is active — latest > activeSince.
const second = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_ask_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
),
)
await Bun.sleep(10)
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
await expect(
Question.ask({
sessionID,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go" },
{ label: "No", description: "Stop" },
],
},
],
}),
).rejects.toBeInstanceOf(Question.RejectedError)
expect(await Question.list()).toEqual([])
release.resolve()
expect(await first).toBe("first")
expect(await second).toBe("second")
},
})
})
})
@@ -604,4 +604,136 @@ describe("session prompt queue", () => {
},
})
})
test("auto-dismisses a suggestion shown after a queued prompt", async () => {
// Reverse ordering of the "new prompt dismisses a pending suggestion" test:
// queue the follow-up first, then open the blocker. Suggestion.show must see
// hasFollowup=true and reject synchronously, before any pending entry or
// Shown event is published.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_suggestion")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
// Slot 1: active, activeSince snapshots latest=1.
const first = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_auto_sug_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
),
)
await started.promise
// Slot 2: enqueued while slot 1 is active → latest=2 > activeSince=1.
const second = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_auto_sug_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
),
)
await Bun.sleep(10)
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
let shown = 0
const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => {
if (event.properties.sessionID === sessionID) shown++
})
try {
await expect(
Suggestion.show({
sessionID,
text: "Run review?",
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
}),
).rejects.toBeInstanceOf(Suggestion.DismissedError)
} finally {
offShown()
}
expect(shown).toBe(0)
expect(await Suggestion.list()).toEqual([])
release.resolve()
expect(await first).toBe("first")
expect(await second).toBe("second")
},
})
})
test("auto-dismisses a question shown after a queued prompt", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_question")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const first = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_auto_q_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
),
)
await started.promise
const second = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_auto_q_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
),
)
await Bun.sleep(10)
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
let asked = 0
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
if (event.properties.sessionID === sessionID) asked++
})
try {
await expect(
Question.ask({
sessionID,
questions: [
{
header: "Continue?",
question: "Should I continue?",
options: [
{ label: "Yes", description: "Go ahead" },
{ label: "No", description: "Stop" },
],
},
],
}),
).rejects.toBeInstanceOf(Question.RejectedError)
} finally {
offAsked()
}
expect(asked).toBe(0)
expect(await Question.list()).toEqual([])
release.resolve()
expect(await first).toBe("first")
expect(await second).toBe("second")
},
})
})
})
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { KiloSessionPromptQueue } from "../../../src/kilocode/session/prompt-queue"
import { Suggestion } from "../../../src/kilocode/suggestion"
import { Instance } from "../../../src/project/instance"
import { MessageID, SessionID } from "../../../src/session/schema"
import { tmpdir } from "../../fixture/fixture"
describe("Suggestion.show auto-dismiss on queued followup", () => {
test("show rejects immediately when a followup is queued on the session", async () => {
// A tool that calls Suggestion.show after a queued prompt has arrived would
// otherwise block the turn on user input. Verify the pre-emptive
// hasFollowup check rejects with DismissedError before any pending entry
// is registered or a Shown event is published.
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const sessionID = SessionID.make("ses_auto_show")
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
// Slot 1 stays running so activeSince is pinned to its seq.
const first = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_show_1"),
Effect.gen(function* () {
started.resolve()
yield* Effect.promise(() => release.promise)
return "first" as const
}),
Effect.succeed("first-cancelled" as const),
),
)
await started.promise
// Slot 2 arrives while slot 1 is active — latest > activeSince.
const second = Effect.runPromise(
KiloSessionPromptQueue.enqueue(
sessionID,
MessageID.make("message_show_2"),
Effect.succeed("second" as const),
Effect.succeed("second-cancelled" as const),
),
)
await Bun.sleep(10)
expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true)
await expect(
Suggestion.show({
sessionID,
text: "Run review?",
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
}),
).rejects.toBeInstanceOf(Suggestion.DismissedError)
expect(await Suggestion.list()).toEqual([])
release.resolve()
expect(await first).toBe("first")
expect(await second).toBe("second")
},
})
})
})