mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
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:
@@ -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")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user