diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 57eebd7012..33e73f3f74 100644 --- a/packages/opencode/src/kilocode/suggestion/index.ts +++ b/packages/opencode/src/kilocode/suggestion/index.ts @@ -1,8 +1,10 @@ import { Bus } from "../../bus" import { BusEvent } from "../../bus/bus-event" import { Identifier } from "../../id/id" +import { SessionID } from "../../session/schema" import { Log } from "../../util/log" import z from "zod" +import { KiloSessionPromptQueue } from "../session/prompt-queue" export namespace Suggestion { const log = Log.create({ service: "suggestion" }) @@ -90,6 +92,14 @@ export namespace Suggestion { blocking?: boolean tool?: { messageID: string; callID: string } }): Promise { + // Auto-dismiss if a newer prompt is already queued on this session. + // Synchronous check immediately before the pending set, so there's no + // interleaving with dismissAll called from SessionPrompt.prompt. + if (KiloSessionPromptQueue.hasFollowup(SessionID.make(input.sessionID))) { + log.info("auto-dismissed — followup queued", { sessionID: input.sessionID }) + throw new DismissedError() + } + const s = { pending } const id = Identifier.ascending("suggestion") diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 539fd1151e..3f533ab5af 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -8,6 +8,9 @@ import { Log } from "@/util/log" import { withStatics } from "@/util/schema" import { QuestionID } from "./schema" import { makeRuntime } from "@/effect/run-service" // kilocode_change +// kilocode_change start +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" +// kilocode_change end export namespace Question { const log = Log.create({ service: "question" }) @@ -195,6 +198,15 @@ export namespace Question { blocking: input.blocking, // kilocode_change tool: input.tool, }) + + // kilocode_change start — auto-dismiss when a newer prompt is queued on this session, + // otherwise a tool that calls Question.ask after the queue event would block the run. + if (KiloSessionPromptQueue.hasFollowup(input.sessionID)) { + log.info("auto-dismissed — followup queued", { sessionID: input.sessionID }) + return yield* Effect.fail(new RejectedError()) + } + // kilocode_change end + pending.set(id, { info, deferred }) yield* bus.publish(Event.Asked, info) diff --git a/packages/opencode/test/kilocode/question-dismiss-all.test.ts b/packages/opencode/test/kilocode/question-dismiss-all.test.ts index acf5a835a0..002765bd34 100644 --- a/packages/opencode/test/kilocode/question-dismiss-all.test.ts +++ b/packages/opencode/test/kilocode/question-dismiss-all.test.ts @@ -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() + const release = Promise.withResolvers() + + // 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") + }, + }) + }) }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index ce35e00f4d..df5ec27f10 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -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() + const release = Promise.withResolvers() + + // 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() + const release = Promise.withResolvers() + + 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") + }, + }) + }) }) diff --git a/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts b/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts new file mode 100644 index 0000000000..d8c6a19122 --- /dev/null +++ b/packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts @@ -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() + const release = Promise.withResolvers() + + // 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") + }, + }) + }) +})