From 0bda9d15ed5ef99fe149fd680a813ca3b4c1d050 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Tue, 21 Apr 2026 19:17:02 +0300 Subject: [PATCH 01/12] fix(cli): restore mid-turn injection --- .changeset/restore-hot-inject.md | 5 ++ packages/opencode/src/session/prompt.ts | 9 ++- .../kilocode/prompt-dismiss-contract.test.ts | 8 +- .../kilocode/session-prompt-queue.test.ts | 80 +++++++++---------- 4 files changed, 55 insertions(+), 47 deletions(-) create mode 100644 .changeset/restore-hot-inject.md diff --git a/.changeset/restore-hot-inject.md b/.changeset/restore-hot-inject.md new file mode 100644 index 00000000000..0a27ad7dc7d --- /dev/null +++ b/.changeset/restore-hot-inject.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Restore mid-turn message injection. Sending a new message while the agent is running now cancels the current turn and processes the new message immediately. Pending review suggestions are dismissed automatically so a new prompt after a review is never stuck behind a showing suggestion. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0b1520da2a8..a0b4c80052c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1307,9 +1307,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (input.noReply === true) return message - // kilocode_change start — dismiss pending suggestions so a previous loop - // blocked on a suggestion can settle before the queue runs the next prompt + // kilocode_change start — hot-inject semantics: cancel any in-flight loop + // and drop any queued follow-ups so the new prompt runs immediately. + // Dismissing pending suggestions also unblocks the in-flight loop if it was + // waiting on Suggestion.show() — the suggest tool's abort listener then + // resolves the suggestion promise on cancel. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) + yield* KiloSessionPromptQueue.cancel(input.sessionID) + yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( input.sessionID, diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index 0ce893e44c1..c0db325701c 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -23,12 +23,12 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll runs before the prompt queue enqueues the new loop", () => { + test("dismissAll and cancel run before the prompt queue enqueues the new loop", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll must precede KiloSessionPromptQueue.enqueue so a previous loop - // blocked on a suggestion can settle before the queue starts the next prompt. + // dismissAll must precede queue/state cancellation so a previous loop blocked + // on a suggestion can settle before the replacement prompt restarts the loop. const block = content.match( - /kilocode_change start[^\n]*dismiss[\s\S]*?Suggestion\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.cancel\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index ee35913122c..8c47327552f 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -221,11 +221,10 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) - test("continues a queued prompt after the active run finishes", async () => { + test("cancels the in-flight turn when a new prompt arrives", async () => { const ready = Promise.withResolvers() - const release = Promise.withResolvers() + const injected = Promise.withResolvers() const calls: number[] = [] - const replies = ["first reply", "second reply", "third reply"] const server = Bun.serve({ port: 0, fetch(req) { @@ -235,8 +234,8 @@ describe("session prompt queue", () => { calls.push(Date.now()) const body = calls.length === 1 - ? reply({ text: replies[0], ready: ready.resolve, wait: release.promise }) - : reply({ text: replies[calls.length - 1] ?? "extra reply" }) + ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + : reply({ text: "second reply", ready: injected.resolve }) return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, @@ -288,43 +287,40 @@ describe("session prompt queue", () => { agent: "code", parts: [{ type: "text", text: "second prompt" }], }) - const third = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "third prompt" }], - }) - await Bun.sleep(20) - expect(calls).toHaveLength(1) - const queued = await Session.messages({ sessionID: session.id }) - expect(queued.filter((msg) => msg.info.role === "user")).toHaveLength(3) - expect(queued.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) + await injected.promise + expect(calls).toHaveLength(2) - release.resolve() - await first + const one = await first const two = await second - const three = await third + expect(one.info.role).toBe("assistant") expect(hasText(two, "second reply")).toBe(true) - expect(hasText(three, "third reply")).toBe(true) - expect(calls).toHaveLength(3) + expect(calls).toHaveLength(2) const msgs = await Session.messages({ sessionID: session.id }) const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") + const prompts = users.flatMap((msg) => + msg.parts.filter((part) => part.type === "text").map((part) => part.text), + ) const text = assistants.flatMap((msg) => msg.parts.filter((part) => part.type === "text").map((part) => part.text), ) - expect(users).toHaveLength(3) - expect(assistants).toHaveLength(3) - expect(text).toContain("first reply") + expect(users).toHaveLength(2) + expect(prompts).toContain("first prompt") + expect(prompts).toContain("second prompt") expect(text).toContain("second reply") - expect(text).toContain("third reply") - for (const [index, item] of assistants.entries()) { - const user = users[index]?.info - if (item.info.role !== "assistant" || user?.role !== "user") throw new Error("missing turn") - expect(item.info.parentID).toBe(user.id) + expect(text).not.toContain("first reply") + + const latest = assistants.find((msg) => hasText(msg, "second reply")) + const secondUser = users.find((msg) => hasText(msg, "second prompt")) + expect(latest?.info.role).toBe("assistant") + expect(secondUser?.info.role).toBe("user") + if (latest?.info.role !== "assistant" || secondUser?.info.role !== "user") { + throw new Error("missing hot-injected turn") } + expect(latest.info.parentID).toBe(secondUser.info.id) }, }) } finally { @@ -332,8 +328,9 @@ describe("session prompt queue", () => { } }) - test("cancel drops queued prompts and resets internal state", async () => { + test("cancel resets internal state after a hot-injected prompt replaces the active turn", async () => { const ready = Promise.withResolvers() + const injected = Promise.withResolvers() const calls: number[] = [] const server = Bun.serve({ port: 0, @@ -342,7 +339,10 @@ describe("session prompt queue", () => { if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) calls.push(Date.now()) - const body = reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + const body = + calls.length === 1 + ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + : reply({ text: "second reply", ready: injected.resolve, wait: new Promise(() => {}) }) return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, @@ -386,23 +386,21 @@ describe("session prompt queue", () => { agent: "code", parts: [{ type: "text", text: "second prompt" }], }) - const third = SessionPrompt.prompt({ - sessionID: session.id, - agent: "code", - parts: [{ type: "text", text: "third prompt" }], - }) - await Bun.sleep(20) - expect(calls).toHaveLength(1) + await injected.promise + expect(calls).toHaveLength(2) await SessionPrompt.cancel(session.id) - await Promise.all([first, second, third]) + const [one, two] = await Promise.all([first, second]) - expect(calls).toHaveLength(1) + expect(one.info.role).toBe("assistant") + expect(two.info.role).toBe("assistant") + expect(calls).toHaveLength(2) const msgs = await Session.messages({ sessionID: session.id }) + const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(1) - expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) + expect(users).toHaveLength(2) + expect(assistants).toHaveLength(2) // Internal state should have no lingering tail/version/target entries after the last release. const ids = await Effect.runPromise( From d500a983b9b315a0bba7dc292a07c417fc94f683 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 09:53:56 +0300 Subject: [PATCH 02/12] fix(cli): reserve latest injected prompt --- .../src/kilocode/session/prompt-queue.ts | 30 ++++++- packages/opencode/src/session/prompt.ts | 2 +- .../kilocode/prompt-dismiss-contract.test.ts | 10 ++- .../kilocode/session-prompt-queue.test.ts | 89 +++++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index fa520935b63..af100e93353 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -9,6 +9,12 @@ type Slot = { readonly tail: Promise } +type Reserve = { + readonly id: number + readonly version: number + readonly previous: Promise +} + type Target = { readonly base: MessageID readonly extras: ReadonlySet @@ -18,6 +24,8 @@ export namespace KiloSessionPromptQueue { const tails = new Map>() const versions = new Map() const targets = new Map() + const reserved = new Map() + let ids = 0 const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0 const settle = (promise: Promise) => @@ -32,6 +40,20 @@ export namespace KiloSessionPromptQueue { }) } + export function reserve(sessionID: SessionID) { + return Effect.sync(() => { + const next = version(sessionID) + 1 + versions.set(sessionID, next) + const slot = { + id: ++ids, + version: next, + previous: tails.get(sessionID) ?? Promise.resolve(), + } satisfies Reserve + reserved.set(sessionID, slot) + return slot + }) + } + /** * Exempt an injected user message from being hidden by scope(). * Called after PlanFollowup.inject() so the injected follow-up is visible @@ -90,12 +112,16 @@ export namespace KiloSessionPromptQueue { ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { - const previous = tails.get(sessionID) ?? Promise.resolve() + const held = reserved.get(sessionID) + const same = !!held && held.previous === tails.get(sessionID) && held.version === version(sessionID) + const seed = same ? held : undefined + if (seed) reserved.delete(sessionID) + const previous = seed?.previous ?? tails.get(sessionID) ?? Promise.resolve() const done = Promise.withResolvers() // Keep later queued prompts moving; each caller still observes its own failure. const tail = settle(previous).then(() => done.promise) tails.set(sessionID, tail) - return { version: version(sessionID), previous, done, tail } satisfies Slot + return { version: seed?.version ?? version(sessionID), previous, done, tail } satisfies Slot }), (slot) => Effect.promise(() => settle(slot.previous)).pipe( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a0b4c80052c..6cab88e3cb5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1313,7 +1313,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // waiting on Suggestion.show() — the suggest tool's abort listener then // resolves the suggestion promise on cancel. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - yield* KiloSessionPromptQueue.cancel(input.sessionID) + yield* KiloSessionPromptQueue.reserve(input.sessionID) yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index c0db325701c..6a7ee74ddc0 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -23,12 +23,14 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll and cancel run before the prompt queue enqueues the new loop", () => { + test("dismissAll and reserve run before the prompt queue enqueues the new loop", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll must precede queue/state cancellation so a previous loop blocked - // on a suggestion can settle before the replacement prompt restarts the loop. + // dismissAll must precede queue reservation/state cancellation so a previous + // loop blocked on a suggestion can settle before the replacement prompt + // restarts the loop, while still letting newer prompts supersede older + // replacements during the cancel window. const block = content.match( - /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.cancel\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 8c47327552f..8bc7e9e6cbf 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -1,7 +1,9 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Effect } from "effect" +import { Bus } from "../../src/bus" import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" +import { Suggestion } from "../../src/kilocode/suggestion" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" @@ -221,6 +223,48 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) + test("retains distinct reserved versions during rapid replacement", async () => { + const sessionID = SessionID.make("session_reserve_race") + const gate = Promise.withResolvers() + const runs: string[] = [] + + const one = Effect.runPromise( + Effect.gen(function* () { + yield* KiloSessionPromptQueue.reserve(sessionID) + return yield* KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_b"), + Effect.promise(() => gate.promise).pipe(Effect.as("b")), + Effect.succeed("b-cancelled"), + ) + }), + ) + + const two = Effect.runPromise( + Effect.gen(function* () { + yield* KiloSessionPromptQueue.reserve(sessionID) + return yield* KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_c"), + Effect.sync(() => { + runs.push("c") + return "c" + }), + Effect.sync(() => { + runs.push("c-cancelled") + return "c-cancelled" + }), + ) + }), + ) + + gate.resolve() + + expect(await one).toBe("b-cancelled") + expect(await two).toBe("c") + expect(runs).toEqual(["c"]) + }) + test("cancels the in-flight turn when a new prompt arrives", async () => { const ready = Promise.withResolvers() const injected = Promise.withResolvers() @@ -418,4 +462,49 @@ describe("session prompt queue", () => { server.stop(true) } }) + + test("new prompt dismisses a pending suggestion", async () => { + const shown = Promise.withResolvers() + const dismissed = Promise.withResolvers() + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Suggestion unblock regression" }) + const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => { + if (event.properties.sessionID === session.id) shown.resolve() + }) + const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => { + if (event.properties.sessionID === session.id) dismissed.resolve() + }) + + try { + const base = Suggestion.show({ + sessionID: session.id, + text: "Run review?", + actions: [{ label: "Review", prompt: "/local-review-uncommitted" }], + }).catch((err) => { + if (err instanceof Suggestion.DismissedError) return "dismissed" + throw err + }) + + await shown.promise + await SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "replacement prompt" }], + noReply: true, + }) + await dismissed.promise + + expect(await base).toBe("dismissed") + expect(await Suggestion.list()).toEqual([]) + } finally { + offShown() + offDismissed() + } + }, + }) + }) }) From c5ca65730614ca8e6548eeef5dcd70e7de2d1d6f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 13:49:26 +0300 Subject: [PATCH 03/12] fix(cli): bind reservation owner --- .../src/kilocode/session/prompt-queue.ts | 6 +- packages/opencode/src/session/prompt.ts | 3 +- .../kilocode/prompt-dismiss-contract.test.ts | 2 +- .../kilocode/session-prompt-queue.test.ts | 79 ++++++++++++------- 4 files changed, 56 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index af100e93353..cfcc6f645a8 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -109,19 +109,19 @@ export namespace KiloSessionPromptQueue { target: MessageID, work: Effect.Effect, cancelled: Effect.Effect, + hold?: Reserve, ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { const held = reserved.get(sessionID) - const same = !!held && held.previous === tails.get(sessionID) && held.version === version(sessionID) - const seed = same ? held : undefined + const seed = held && hold && held.id === hold.id ? held : undefined if (seed) reserved.delete(sessionID) const previous = seed?.previous ?? tails.get(sessionID) ?? Promise.resolve() const done = Promise.withResolvers() // Keep later queued prompts moving; each caller still observes its own failure. const tail = settle(previous).then(() => done.promise) tails.set(sessionID, tail) - return { version: seed?.version ?? version(sessionID), previous, done, tail } satisfies Slot + return { version: hold?.version ?? version(sessionID), previous, done, tail } satisfies Slot }), (slot) => Effect.promise(() => settle(slot.previous)).pipe( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6cab88e3cb5..26de4e6ec5c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1313,7 +1313,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the // waiting on Suggestion.show() — the suggest tool's abort listener then // resolves the suggestion promise on cancel. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - yield* KiloSessionPromptQueue.reserve(input.sessionID) + const hold = yield* KiloSessionPromptQueue.reserve(input.sessionID) yield* state.cancel(input.sessionID) // kilocode_change end return yield* KiloSessionPromptQueue.enqueue( @@ -1321,6 +1321,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the message.info.id, loop({ sessionID: input.sessionID }), lastAssistant(input.sessionID), + hold, ) }, ) diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index 6a7ee74ddc0..df2d0b1e355 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -30,7 +30,7 @@ describe("prompt.ts Kilo-specific invariants", () => { // restarts the loop, while still letting newer prompts supersede older // replacements during the cancel window. const block = content.match( - /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?const hold = yield\* KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue\([\s\S]*?hold/, ) expect(block).not.toBeNull() }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 8bc7e9e6cbf..2d2ab73d2e4 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -225,44 +225,65 @@ describe("session prompt queue", () => { test("retains distinct reserved versions during rapid replacement", async () => { const sessionID = SessionID.make("session_reserve_race") + const ready = Promise.withResolvers() const gate = Promise.withResolvers() const runs: string[] = [] - const one = Effect.runPromise( - Effect.gen(function* () { - yield* KiloSessionPromptQueue.reserve(sessionID) - return yield* KiloSessionPromptQueue.enqueue( - sessionID, - MessageID.make("message_b"), - Effect.promise(() => gate.promise).pipe(Effect.as("b")), - Effect.succeed("b-cancelled"), - ) - }), + const base = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_a"), + Effect.sync(() => ready.resolve()).pipe( + Effect.flatMap(() => Effect.promise(() => gate.promise)), + Effect.as("a"), + ), + Effect.succeed("a-cancelled"), + ), ) - const two = Effect.runPromise( - Effect.gen(function* () { - yield* KiloSessionPromptQueue.reserve(sessionID) - return yield* KiloSessionPromptQueue.enqueue( - sessionID, - MessageID.make("message_c"), - Effect.sync(() => { - runs.push("c") - return "c" - }), - Effect.sync(() => { - runs.push("c-cancelled") - return "c-cancelled" - }), - ) - }), + await ready.promise + + const one = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) + const two = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) + + const first = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_b"), + Effect.sync(() => { + runs.push("b") + return "b" + }), + Effect.sync(() => { + runs.push("b-cancelled") + return "b-cancelled" + }), + one, + ), + ) + + const second = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_c"), + Effect.sync(() => { + runs.push("c") + return "c" + }), + Effect.sync(() => { + runs.push("c-cancelled") + return "c-cancelled" + }), + two, + ), ) gate.resolve() - expect(await one).toBe("b-cancelled") - expect(await two).toBe("c") - expect(runs).toEqual(["c"]) + expect(await base).toBe("a") + expect(await first).toBe("b-cancelled") + expect(await second).toBe("c") + expect(runs).toEqual(["b-cancelled", "c"]) }) test("cancels the in-flight turn when a new prompt arrives", async () => { From 311a73404ba095c07af54ced20f439fdaa41ab0d Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 16:00:12 +0300 Subject: [PATCH 04/12] fix(cli): break after current stream instead of aborting on new prompt Replace the state.cancel-based mid-turn injection with a break-after-stream queue. When a new prompt arrives while the assistant is streaming, the current LLM step now finishes cleanly, any pending suggest/question tool auto-dismisses via Suggestion.dismissAll and the new Question.dismissAll, and runLoop breaks out before the next LLM step via KiloSessionPromptQueue.hasFollowup. Queued prompts still run in order, each getting a full turn unless a newer one arrives during it. --- .changeset/restore-hot-inject.md | 2 +- .../src/kilocode/session/prompt-queue.ts | 58 ++--- packages/opencode/src/question/index.ts | 22 +- packages/opencode/src/session/prompt.ts | 29 ++- packages/opencode/src/tool/question.ts | 24 +- .../kilocode/prompt-dismiss-contract.test.ts | 36 ++- .../kilocode/question-dismiss-all.test.ts | 108 +++++++++ .../kilocode/session-prompt-queue.test.ts | 224 ++++++++++++------ 8 files changed, 376 insertions(+), 127 deletions(-) create mode 100644 packages/opencode/test/kilocode/question-dismiss-all.test.ts diff --git a/.changeset/restore-hot-inject.md b/.changeset/restore-hot-inject.md index 0a27ad7dc7d..48daa9a34a3 100644 --- a/.changeset/restore-hot-inject.md +++ b/.changeset/restore-hot-inject.md @@ -2,4 +2,4 @@ "kilo-code": patch --- -Restore mid-turn message injection. Sending a new message while the agent is running now cancels the current turn and processes the new message immediately. Pending review suggestions are dismissed automatically so a new prompt after a review is never stuck behind a showing suggestion. +Fix mid-turn message handling so a new prompt sent while the assistant is working no longer aborts the in-flight response. The current LLM reply streams to completion, any pending suggestion or question is automatically dismissed, and the new prompt runs immediately after the current step instead of waiting for the entire multi-step turn to finish. diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index cfcc6f645a8..04df601c98e 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -3,18 +3,13 @@ import { MessageV2 } from "@/session/message-v2" import { MessageID, SessionID } from "@/session/schema" type Slot = { + readonly seq: number readonly version: number readonly previous: Promise readonly done: PromiseWithResolvers readonly tail: Promise } -type Reserve = { - readonly id: number - readonly version: number - readonly previous: Promise -} - type Target = { readonly base: MessageID readonly extras: ReadonlySet @@ -24,8 +19,13 @@ export namespace KiloSessionPromptQueue { const tails = new Map>() const versions = new Map() const targets = new Map() - const reserved = new Map() - let ids = 0 + // Monotonic arrival counter per session. latest holds the seq of the most + // recently enqueued slot; activeSince snapshots latest at the moment the + // currently running slot actually started. hasFollowup returns true only when + // a newer slot was enqueued after the active one began running. + const latest = new Map() + const activeSince = new Map() + let seq = 0 const version = (sessionID: SessionID) => versions.get(sessionID) ?? 0 const settle = (promise: Promise) => @@ -40,20 +40,6 @@ export namespace KiloSessionPromptQueue { }) } - export function reserve(sessionID: SessionID) { - return Effect.sync(() => { - const next = version(sessionID) + 1 - versions.set(sessionID, next) - const slot = { - id: ++ids, - version: next, - previous: tails.get(sessionID) ?? Promise.resolve(), - } satisfies Reserve - reserved.set(sessionID, slot) - return slot - }) - } - /** * Exempt an injected user message from being hidden by scope(). * Called after PlanFollowup.inject() so the injected follow-up is visible @@ -67,6 +53,18 @@ export namespace KiloSessionPromptQueue { targets.set(sessionID, { base: current.base, extras }) } + /** + * True when a newer prompt was enqueued after the currently running slot + * began. runLoop calls this between LLM steps to break out so the next + * queued prompt can take over without starting another LLM round-trip for + * the now-superseded turn. + */ + export function hasFollowup(sessionID: SessionID): boolean { + const l = latest.get(sessionID) ?? 0 + const a = activeSince.get(sessionID) ?? 0 + return l > a + } + export function scope(sessionID: SessionID, messages: MessageV2.WithParts[]) { const target = targets.get(sessionID) if (!target) return messages @@ -109,24 +107,26 @@ export namespace KiloSessionPromptQueue { target: MessageID, work: Effect.Effect, cancelled: Effect.Effect, - hold?: Reserve, ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { - const held = reserved.get(sessionID) - const seed = held && hold && held.id === hold.id ? held : undefined - if (seed) reserved.delete(sessionID) - const previous = seed?.previous ?? tails.get(sessionID) ?? Promise.resolve() + const mine = ++seq + latest.set(sessionID, mine) + const previous = tails.get(sessionID) ?? Promise.resolve() const done = Promise.withResolvers() // Keep later queued prompts moving; each caller still observes its own failure. const tail = settle(previous).then(() => done.promise) tails.set(sessionID, tail) - return { version: hold?.version ?? version(sessionID), previous, done, tail } satisfies Slot + return { seq: mine, version: version(sessionID), previous, done, tail } satisfies Slot }), (slot) => Effect.promise(() => settle(slot.previous)).pipe( Effect.flatMap(() => { if (slot.version !== version(sessionID)) return cancelled + // Snapshot the latest seq at the moment this slot actually starts + // running. hasFollowup compares against this value so the slot only + // breaks when something newer than itself arrives. + activeSince.set(sessionID, latest.get(sessionID) ?? slot.seq) return Effect.acquireUseRelease( Effect.sync(() => { targets.set(sessionID, { base: target, extras: new Set() }) @@ -146,6 +146,8 @@ export namespace KiloSessionPromptQueue { tails.delete(sessionID) versions.delete(sessionID) targets.delete(sessionID) + latest.delete(sessionID) + activeSince.delete(sessionID) }), ) } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 6a1e5e246fe..539fd1151ec 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -149,6 +149,7 @@ export namespace Question { readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray }) => Effect.Effect readonly reject: (requestID: QuestionID) => Effect.Effect readonly list: () => Effect.Effect> + readonly dismissAll: (sessionID: SessionID) => Effect.Effect // kilocode_change } export class Service extends Context.Service()("@opencode/Question") {} @@ -246,7 +247,25 @@ export namespace Question { return Array.from(pending.values(), (x) => x.info) }) - return Service.of({ ask, reply, reject, list }) + // kilocode_change start - dismiss every pending question on a session so a new + // prompt can unblock an in-flight tool waiting on user input. Mirrors + // Suggestion.dismissAll so both read the same way at the callsite. + const dismissAll = Effect.fn("Question.dismissAll")(function* (sessionID: SessionID) { + const pending = (yield* InstanceState.get(state)).pending + const matches = Array.from(pending.entries()).filter(([, entry]) => entry.info.sessionID === sessionID) + for (const [id, entry] of matches) { + pending.delete(id) + log.info("dismissed", { requestID: id }) + yield* bus.publish(Event.Rejected, { + sessionID: entry.info.sessionID, + requestID: entry.info.id, + }) + yield* Deferred.fail(entry.deferred, new RejectedError()) + } + }) + // kilocode_change end + + return Service.of({ ask, reply, reject, list, dismissAll }) // kilocode_change }), ) @@ -258,5 +277,6 @@ export namespace Question { export const ask = (input: Parameters[0]) => runPromise((svc) => svc.ask(input)) export const reply = (input: Parameters[0]) => runPromise((svc) => svc.reply(input)) export const reject = (requestID: QuestionID) => runPromise((svc) => svc.reject(requestID)) + export const dismissAll = (sessionID: string) => runPromise((svc) => svc.dismissAll(SessionID.make(sessionID))) // kilocode_change end } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 26de4e6ec5c..59734565546 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,6 +5,7 @@ import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change +import { Question } from "@/question" // kilocode_change import z from "zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -1306,22 +1307,23 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) } - if (input.noReply === true) return message - // kilocode_change start — hot-inject semantics: cancel any in-flight loop - // and drop any queued follow-ups so the new prompt runs immediately. - // Dismissing pending suggestions also unblocks the in-flight loop if it was - // waiting on Suggestion.show() — the suggest tool's abort listener then - // resolves the suggestion promise on cancel. + // kilocode_change start — unblock tools waiting on user input so any in-flight + // handle.process can return. Adding a new user message is the signal that any + // pending tool prompt is superseded, so we dismiss even on the noReply path. + // Critically we never cancel the in-flight fiber here — that would abort the + // streamText call mid-tokens and cut off the assistant reply. The enqueue call + // below serializes this prompt after the current turn's current LLM step, and + // runLoop checks hasFollowup between steps to break out once it has been + // enqueued during the turn. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - const hold = yield* KiloSessionPromptQueue.reserve(input.sessionID) - yield* state.cancel(input.sessionID) + yield* Effect.promise(() => Question.dismissAll(input.sessionID)) // kilocode_change end + if (input.noReply === true) return message return yield* KiloSessionPromptQueue.enqueue( input.sessionID, message.info.id, loop({ sessionID: input.sessionID }), lastAssistant(input.sessionID), - hold, ) }, ) @@ -1583,6 +1585,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the overflow: !handle.message.finish, }) } + // kilocode_change start — break out so a newer queued prompt can take over + // instead of starting another LLM step for the now-superseded turn. The + // current handle.process has fully drained (tokens + inline tool calls) by + // the time we get here, so nothing is cut off. + if (KiloSessionPromptQueue.hasFollowup(sessionID)) { + closeReasons.set(sessionID, "interrupted") + return "break" as const + } + // kilocode_change end return "continue" as const }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) if (outcome === "break") break diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index 50e4b1c5112..f3732f17ab4 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -22,11 +22,25 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const answers = yield* question.ask({ - sessionID: ctx.sessionID, - questions: params.questions, - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }) + // kilocode_change start - gracefully surface RejectedError (e.g. from Question.dismissAll + // when a new prompt arrives mid-question) as a "dismissed" outcome instead of turning it + // into a defect via Effect.orDie, which would kill the in-flight stream. + const answers = yield* question + .ask({ + sessionID: ctx.sessionID, + questions: params.questions, + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + .pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed<"dismissed">("dismissed"))) + if (answers === "dismissed") { + const dismissed: Metadata = { answers: [] } + return { + title: "Question dismissed", + output: "User dismissed the question.", + metadata: dismissed, + } + } + // kilocode_change end const formatted = params.questions .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`) diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index df2d0b1e355..2badf38a245 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -1,9 +1,11 @@ /** * Contract test for prompt.ts Kilo-specific invariants. * - * prompt.ts is a shared upstream file. PR #8988 added Suggestion.dismissAll - * there with kilocode_change markers. An upstream merge that restructures - * the prompt handling could silently remove this call — this test catches that. + * prompt.ts is a shared upstream file. The Kilo-specific "new prompt unblocks + * pending suggestions/questions then enqueues without cancelling the in-flight + * stream" behaviour lives inside a kilocode_change block. An upstream merge + * that restructures the prompt handling could silently remove these calls — + * this test catches that. */ import { describe, test, expect } from "bun:test" @@ -18,20 +20,36 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toMatch(/import\s*\{[^}]*Suggestion[^}]*\}\s*from\s*["']@\/kilocode\/suggestion["']/) }) + test("imports Question from the question module", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + expect(content).toMatch(/import\s*\{[^}]*Question[^}]*\}\s*from\s*["']@\/question["']/) + }) + test("calls Suggestion.dismissAll before restarting the session loop", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll and reserve run before the prompt queue enqueues the new loop", () => { + test("dismissAll for suggestions and questions runs before enqueue, without cancelling the in-flight fiber", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll must precede queue reservation/state cancellation so a previous - // loop blocked on a suggestion can settle before the replacement prompt - // restarts the loop, while still letting newer prompts supersede older - // replacements during the cancel window. + // dismissAll for both suggestions and questions must precede the enqueue so + // an in-flight handle.process blocked on a pending tool prompt can return. + // Critically, the block must NOT call state.cancel or KiloSessionPromptQueue.reserve — + // either of those would abort the running streamText mid-tokens, which was + // the #9332 regression. Order: dismissAll(Suggestion) → dismissAll(Question) → enqueue. const block = content.match( - /kilocode_change start[^\n]*hot-inject[\s\S]*?Suggestion\.dismissAll[\s\S]*?const hold = yield\* KiloSessionPromptQueue\.reserve\(input\.sessionID\)[\s\S]*?state\.cancel\(input\.sessionID\)[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue\([\s\S]*?hold/, + /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() + expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/) + expect(content).not.toMatch(/KiloSessionPromptQueue\.reserve/) + }) + + test("runLoop breaks out between LLM steps when a newer prompt was enqueued", () => { + const content = fs.readFileSync(PROMPT_FILE, "utf-8") + // hasFollowup has to be checked inside runLoop so the current handle.process + // finishes naturally (tokens + inline tool calls) and the next LLM step is + // skipped when a follow-up is already queued. + expect(content).toContain("KiloSessionPromptQueue.hasFollowup(sessionID)") }) }) diff --git a/packages/opencode/test/kilocode/question-dismiss-all.test.ts b/packages/opencode/test/kilocode/question-dismiss-all.test.ts new file mode 100644 index 00000000000..acf5a835a04 --- /dev/null +++ b/packages/opencode/test/kilocode/question-dismiss-all.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Question } from "../../src/question" +import { SessionID } from "../../src/session/schema" +import { tmpdir } from "../fixture/fixture" + +describe("Question.dismissAll", () => { + test("rejects pending asks for the target session and clears them", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const sesA = SessionID.make("ses_a") + const sesB = SessionID.make("ses_b") + + const a1 = Question.ask({ + sessionID: sesA, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) + + const a2 = Question.ask({ + sessionID: sesA, + questions: [ + { + header: "Retry?", + question: "Try again?", + options: [ + { label: "Retry", description: "Retry" }, + { label: "Cancel", description: "Cancel" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) + + const b1 = Question.ask({ + sessionID: sesB, + questions: [ + { + header: "Deploy?", + question: "Deploy now?", + options: [ + { label: "Ship", description: "Ship" }, + { label: "Wait", description: "Wait" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected-b" + throw err + }) + + // Wait for all three asks to register so we can dismiss them. + for (let i = 0; i < 50; i++) { + if ((await Question.list()).length >= 3) break + await Bun.sleep(10) + } + expect(await Question.list()).toHaveLength(3) + + // Track whether B's promise settles. + let settled = false + b1.then(() => { + settled = true + }) + + await Question.dismissAll("ses_a") + + expect(await a1).toBe("rejected") + expect(await a2).toBe("rejected") + + await new Promise((r) => setTimeout(r, 10)) + expect(settled).toBe(false) + + const remaining = await Question.list() + expect(remaining).toHaveLength(1) + expect(remaining[0]?.sessionID).toBe(sesB) + + await Question.reject(remaining[0]!.id) + expect(await b1).toBe("rejected-b") + }, + }) + }) + + test("is a no-op when no questions exist", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Question.dismissAll("ses_missing") + expect(await Question.list()).toEqual([]) + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 2d2ab73d2e4..ce35e00f4d4 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { Bus } from "../../src/bus" import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" import { Suggestion } from "../../src/kilocode/suggestion" +import { Question } from "../../src/question" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" @@ -223,70 +224,82 @@ describe("session prompt queue", () => { expect(ids[ids.length - 1]).toBe(injected) }) - test("retains distinct reserved versions during rapid replacement", async () => { - const sessionID = SessionID.make("session_reserve_race") - const ready = Promise.withResolvers() - const gate = Promise.withResolvers() - const runs: string[] = [] - - const base = Effect.runPromise( - KiloSessionPromptQueue.enqueue( - sessionID, - MessageID.make("message_a"), - Effect.sync(() => ready.resolve()).pipe( - Effect.flatMap(() => Effect.promise(() => gate.promise)), - Effect.as("a"), - ), - Effect.succeed("a-cancelled"), - ), - ) - - await ready.promise - - const one = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) - const two = await Effect.runPromise(KiloSessionPromptQueue.reserve(sessionID)) + test("hasFollowup reports true only for prompts enqueued after the active slot started", async () => { + const sessionID = SessionID.make("session_followup_semantics") + const observed: Array<{ where: string; value: boolean }> = [] + const firstStarted = Promise.withResolvers() + const firstReleased = Promise.withResolvers() + const secondStarted = Promise.withResolvers() + const secondReleased = Promise.withResolvers() const first = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_b"), - Effect.sync(() => { - runs.push("b") - return "b" + MessageID.make("message_followup_1"), + Effect.gen(function* () { + observed.push({ where: "first:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + firstStarted.resolve() + yield* Effect.promise(() => firstReleased.promise) + observed.push({ where: "first:end", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + return "first" }), - Effect.sync(() => { - runs.push("b-cancelled") - return "b-cancelled" - }), - one, + Effect.succeed("first-cancelled"), ), ) + await firstStarted.promise + // msg1 is alone — nothing newer has arrived yet. + expect(observed[0]?.value).toBe(false) + const second = Effect.runPromise( KiloSessionPromptQueue.enqueue( sessionID, - MessageID.make("message_c"), - Effect.sync(() => { - runs.push("c") - return "c" + MessageID.make("message_followup_2"), + Effect.gen(function* () { + observed.push({ where: "second:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + secondStarted.resolve() + yield* Effect.promise(() => secondReleased.promise) + return "second" }), - Effect.sync(() => { - runs.push("c-cancelled") - return "c-cancelled" - }), - two, + Effect.succeed("second-cancelled"), ), ) - gate.resolve() + // Enqueueing msg2 while msg1 is still running must flip hasFollowup to true + // for msg1's running slot. + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(true) - expect(await base).toBe("a") - expect(await first).toBe("b-cancelled") - expect(await second).toBe("c") - expect(runs).toEqual(["b-cancelled", "c"]) + const third = Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + MessageID.make("message_followup_3"), + Effect.sync(() => { + observed.push({ where: "third:start", value: KiloSessionPromptQueue.hasFollowup(sessionID) }) + return "third" + }), + Effect.succeed("third-cancelled"), + ), + ) + + // Let msg1 finish. + firstReleased.resolve() + await first + await secondStarted.promise + + // msg2 started after msg3 was enqueued, so hasFollowup should be false for + // msg2 — everything waiting is older than msg2's activeSince snapshot. + expect(KiloSessionPromptQueue.hasFollowup(sessionID)).toBe(false) + secondReleased.resolve() + + expect(await second).toBe("second") + expect(await third).toBe("third") + + const events = observed.map((item) => `${item.where}=${item.value}`) + expect(events).toEqual(["first:start=false", "first:end=true", "second:start=false", "third:start=false"]) }) - test("cancels the in-flight turn when a new prompt arrives", async () => { + test("processes queued prompts without aborting the in-flight stream", async () => { const ready = Promise.withResolvers() const injected = Promise.withResolvers() const calls: number[] = [] @@ -299,7 +312,7 @@ describe("session prompt queue", () => { calls.push(Date.now()) const body = calls.length === 1 - ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) + ? reply({ text: "first reply", ready: ready.resolve }) : reply({ text: "second reply", ready: injected.resolve }) return new Response(body, { status: 200, @@ -353,16 +366,18 @@ describe("session prompt queue", () => { parts: [{ type: "text", text: "second prompt" }], }) - await injected.promise - expect(calls).toHaveLength(2) - const one = await first + await injected.promise const two = await second - expect(one.info.role).toBe("assistant") - expect(hasText(two, "second reply")).toBe(true) expect(calls).toHaveLength(2) + // The in-flight stream must complete; no aborted error on msg1's reply. + expect(one.info.role).toBe("assistant") + if (one.info.role === "assistant") expect(one.info.error).toBeUndefined() + expect(hasText(one, "first reply")).toBe(true) + expect(hasText(two, "second reply")).toBe(true) + const msgs = await Session.messages({ sessionID: session.id }) const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") @@ -373,19 +388,26 @@ describe("session prompt queue", () => { msg.parts.filter((part) => part.type === "text").map((part) => part.text), ) expect(users).toHaveLength(2) + expect(assistants).toHaveLength(2) expect(prompts).toContain("first prompt") expect(prompts).toContain("second prompt") + expect(text).toContain("first reply") expect(text).toContain("second reply") - expect(text).not.toContain("first reply") - const latest = assistants.find((msg) => hasText(msg, "second reply")) + const firstUser = users.find((msg) => hasText(msg, "first prompt")) const secondUser = users.find((msg) => hasText(msg, "second prompt")) - expect(latest?.info.role).toBe("assistant") - expect(secondUser?.info.role).toBe("user") - if (latest?.info.role !== "assistant" || secondUser?.info.role !== "user") { - throw new Error("missing hot-injected turn") + const firstReply = assistants.find((msg) => hasText(msg, "first reply")) + const secondReply = assistants.find((msg) => hasText(msg, "second reply")) + if ( + firstUser?.info.role !== "user" || + secondUser?.info.role !== "user" || + firstReply?.info.role !== "assistant" || + secondReply?.info.role !== "assistant" + ) { + throw new Error("missing expected messages") } - expect(latest.info.parentID).toBe(secondUser.info.id) + expect(firstReply.info.parentID).toBe(firstUser.info.id) + expect(secondReply.info.parentID).toBe(secondUser.info.id) }, }) } finally { @@ -393,9 +415,8 @@ describe("session prompt queue", () => { } }) - test("cancel resets internal state after a hot-injected prompt replaces the active turn", async () => { + test("cancel drops queued prompts and resets internal state", async () => { const ready = Promise.withResolvers() - const injected = Promise.withResolvers() const calls: number[] = [] const server = Bun.serve({ port: 0, @@ -404,10 +425,7 @@ describe("session prompt queue", () => { if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) calls.push(Date.now()) - const body = - calls.length === 1 - ? reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) - : reply({ text: "second reply", ready: injected.resolve, wait: new Promise(() => {}) }) + const body = reply({ text: "first reply", ready: ready.resolve, wait: new Promise(() => {}) }) return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" }, @@ -451,21 +469,25 @@ describe("session prompt queue", () => { agent: "code", parts: [{ type: "text", text: "second prompt" }], }) + const third = SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "third prompt" }], + }) - await injected.promise - expect(calls).toHaveLength(2) + // Let msg2/msg3's enqueue capture the current version before cancel bumps it. + await Bun.sleep(20) + expect(calls).toHaveLength(1) await SessionPrompt.cancel(session.id) - const [one, two] = await Promise.all([first, second]) + await Promise.all([first, second, third]) - expect(one.info.role).toBe("assistant") - expect(two.info.role).toBe("assistant") - expect(calls).toHaveLength(2) + // The queued prompts must never reach the LLM once cancel flushes the queue. + expect(calls).toHaveLength(1) const msgs = await Session.messages({ sessionID: session.id }) - const users = msgs.filter((msg) => msg.info.role === "user") const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(users).toHaveLength(2) - expect(assistants).toHaveLength(2) + expect(assistants).toHaveLength(1) + expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3) // Internal state should have no lingering tail/version/target entries after the last release. const ids = await Effect.runPromise( @@ -477,6 +499,7 @@ describe("session prompt queue", () => { ), ) expect(ids).toEqual([]) + expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false) }, }) } finally { @@ -528,4 +551,57 @@ describe("session prompt queue", () => { }, }) }) + + test("new prompt dismisses a pending question", async () => { + const asked = Promise.withResolvers() + const rejected = Promise.withResolvers() + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Question unblock regression" }) + const offAsked = Bus.subscribe(Question.Event.Asked, (event) => { + if (event.properties.sessionID === session.id) asked.resolve() + }) + const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => { + if (event.properties.sessionID === session.id) rejected.resolve() + }) + + try { + const pending = Question.ask({ + sessionID: session.id, + questions: [ + { + header: "Continue?", + question: "Should I continue?", + options: [ + { label: "Yes", description: "Go ahead" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }).catch((err) => { + if (err instanceof Question.RejectedError) return "rejected" + throw err + }) + + await asked.promise + await SessionPrompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "replacement prompt" }], + noReply: true, + }) + await rejected.promise + + expect(await pending).toBe("rejected") + expect(await Question.list()).toEqual([]) + } finally { + offAsked() + offRejected() + } + }, + }) + }) }) From 5221569844b8522230f9077469d7444661e28bea Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 16:03:08 +0300 Subject: [PATCH 05/12] fix(cli): cover moved noReply line in kilocode_change block --- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 59734565546..fd95da77034 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1317,7 +1317,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the // enqueued during the turn. yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) yield* Effect.promise(() => Question.dismissAll(input.sessionID)) - // kilocode_change end if (input.noReply === true) return message return yield* KiloSessionPromptQueue.enqueue( input.sessionID, @@ -1325,6 +1324,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the loop({ sessionID: input.sessionID }), lastAssistant(input.sessionID), ) + // kilocode_change end }, ) // kilocode_change end diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index 2badf38a245..c5981d9f6e7 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -38,7 +38,7 @@ describe("prompt.ts Kilo-specific invariants", () => { // either of those would abort the running streamText mid-tokens, which was // the #9332 regression. Order: dismissAll(Suggestion) → dismissAll(Question) → enqueue. const block = content.match( - /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?kilocode_change end[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?Question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/, ) expect(block).not.toBeNull() expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/) From 6d45e33efbb097bb5a724223ef4bdb5ee67355cd Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 17:49:44 +0300 Subject: [PATCH 06/12] 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 --- .../opencode/src/kilocode/suggestion/index.ts | 10 ++ packages/opencode/src/question/index.ts | 12 ++ .../kilocode/question-dismiss-all.test.ts | 68 ++++++++- .../kilocode/session-prompt-queue.test.ts | 132 ++++++++++++++++++ .../kilocode/suggestion/auto-dismiss.test.ts | 65 +++++++++ 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/kilocode/suggestion/auto-dismiss.test.ts diff --git a/packages/opencode/src/kilocode/suggestion/index.ts b/packages/opencode/src/kilocode/suggestion/index.ts index 57eebd70125..33e73f3f74f 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 539fd1151ec..3f533ab5af6 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 acf5a835a04..002765bd34c 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 ce35e00f4d4..df5ec27f10e 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 00000000000..d8c6a19122f --- /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") + }, + }) + }) +}) From d5492f4765ec08c9879a9f0e1733cf94c6c51d2e Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Wed, 22 Apr 2026 17:58:33 +0300 Subject: [PATCH 07/12] fix(cli): mark dismissed question as blocked --- packages/opencode/src/tool/question.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index f99e12406f7..c820025f5ac 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -10,6 +10,7 @@ const parameters = z.object({ type Metadata = { answers: ReadonlyArray + dismissed?: boolean // kilocode_change } export const QuestionTool = Tool.define( @@ -33,7 +34,7 @@ export const QuestionTool = Tool.define Effect.succeed<"dismissed">("dismissed"))) if (answers === "dismissed") { - const dismissed: Metadata = { answers: [] } + const dismissed: Metadata = { answers: [], dismissed: true } return { title: "Question dismissed", output: "User dismissed the question.", From a9107539e3ab1f86376341c2ab7fac0124059aa5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 11:29:30 +0300 Subject: [PATCH 08/12] refactor(cli): move question tool dismissed helpers to kilocode file --- .../opencode/src/kilocode/tool/question.ts | 27 +++++++++++++++++++ packages/opencode/src/tool/question.ts | 18 +++++-------- 2 files changed, 33 insertions(+), 12 deletions(-) create mode 100644 packages/opencode/src/kilocode/tool/question.ts diff --git a/packages/opencode/src/kilocode/tool/question.ts b/packages/opencode/src/kilocode/tool/question.ts new file mode 100644 index 00000000000..9e093e7c7ee --- /dev/null +++ b/packages/opencode/src/kilocode/tool/question.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" +import { Question } from "@/question" + +/** + * Helpers for the shared `@/tool/question` tool that surface a dismissed-question + * outcome (from `Question.dismissAll` when a new prompt arrives mid-question) as + * a normal tool result instead of letting `Effect.orDie` turn the + * `QuestionRejectedError` into a defect that kills the in-flight stream. + * + * Extracted here so the shared tool file keeps just a one-liner pipe plus an + * early return, minimising the surface area that conflicts with upstream. + */ +export namespace KiloQuestionTool { + const DISMISSED = "dismissed" as const + type Dismissed = typeof DISMISSED + + export const catchDismissed = (eff: Effect.Effect) => + eff.pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed(DISMISSED))) + + export const isDismissed = (v: unknown): v is Dismissed => v === DISMISSED + + export const dismissedResult = () => ({ + title: "Question dismissed", + output: "User dismissed the question.", + metadata: { answers: [] as ReadonlyArray, dismissed: true as const }, + }) +} diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index c820025f5ac..4b52ebd7cfd 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import * as Tool from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" +import { KiloQuestionTool } from "@/kilocode/tool/question" // kilocode_change const parameters = z.object({ questions: z.array(Question.Prompt.zod).describe("Questions to ask"), @@ -23,24 +24,17 @@ export const QuestionTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - // kilocode_change start - gracefully surface RejectedError (e.g. from Question.dismissAll - // when a new prompt arrives mid-question) as a "dismissed" outcome instead of turning it - // into a defect via Effect.orDie, which would kill the in-flight stream. + // kilocode_change start - surface Question.dismissAll's RejectedError as a normal + // tool result via KiloQuestionTool helpers, so Effect.orDie below does not turn + // it into a defect and kill the in-flight stream. const answers = yield* question .ask({ sessionID: ctx.sessionID, questions: params.questions, tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, }) - .pipe(Effect.catchTag("QuestionRejectedError", () => Effect.succeed<"dismissed">("dismissed"))) - if (answers === "dismissed") { - const dismissed: Metadata = { answers: [], dismissed: true } - return { - title: "Question dismissed", - output: "User dismissed the question.", - metadata: dismissed, - } - } + .pipe(KiloQuestionTool.catchDismissed) + if (KiloQuestionTool.isDismissed(answers)) return KiloQuestionTool.dismissedResult() // kilocode_change end const formatted = params.questions From d0e109dcb683d831c0d65092d6e2687a5f2a9b5f Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 11:29:37 +0300 Subject: [PATCH 09/12] refactor(cli): move question dismissAll to kilocode file --- .../opencode/src/kilocode/question/index.ts | 62 +++++++++++++++++++ packages/opencode/src/question/index.ts | 33 +++------- 2 files changed, 70 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/kilocode/question/index.ts diff --git a/packages/opencode/src/kilocode/question/index.ts b/packages/opencode/src/kilocode/question/index.ts new file mode 100644 index 00000000000..b827c866742 --- /dev/null +++ b/packages/opencode/src/kilocode/question/index.ts @@ -0,0 +1,62 @@ +import { Deferred, Effect } from "effect" +import { InstanceState } from "@/effect" +import { Log } from "@/util" +import { SessionID } from "@/session/schema" +import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" + +/** + * Kilo-specific helpers for the shared `@/question` module. + * + * Extracted here so the upstream file keeps just the import, an Interface entry + * for `dismissAll`, and one-liner calls at the use sites — minimising the + * surface area that conflicts with upstream. + */ +export namespace KiloQuestion { + const log = Log.create({ service: "question" }) + + /** Minimal entry shape both helpers need; matches `PendingEntry` in `@/question`. */ + type Entry = { + info: { id: unknown; sessionID: SessionID } + deferred: Deferred.Deferred + } + + /** + * Factory for `Question.dismissAll`: dismisses every pending question on a + * session so a new prompt can unblock an in-flight tool waiting on user + * input. Mirrors `Suggestion.dismissAll` so both read the same way at the + * callsite. + * + * The caller provides a `publishRejected` callback (closed over the already- + * resolved `Bus.Service` in the Question layer) and an error factory so this + * helper stays free of any `@/question` import and dodges a circular dep. + */ + export const makeDismissAll = + (args: { + state: InstanceState.InstanceState<{ pending: Map }> + publishRejected: (entry: PE) => Effect.Effect + makeError: () => PE["deferred"] extends Deferred.Deferred ? E : never + }) => + (sessionID: SessionID) => + Effect.gen(function* () { + const pending = (yield* InstanceState.get(args.state)).pending + for (const [id, entry] of Array.from(pending.entries())) { + if (entry.info.sessionID !== sessionID) continue + pending.delete(id) + log.info("dismissed", { requestID: id }) + yield* args.publishRejected(entry) + yield* Deferred.fail(entry.deferred, args.makeError()) + } + }) + + /** + * Auto-dismiss when a newer prompt is already queued on this session — a + * tool that calls `Question.ask` after the queue event would otherwise block + * the run while the user waits for their queued prompt to take over. + */ + export const guardFollowup = (sessionID: SessionID, makeError: () => E) => + Effect.gen(function* () { + if (!KiloSessionPromptQueue.hasFollowup(sessionID)) return + log.info("auto-dismissed — followup queued", { sessionID }) + return yield* Effect.fail(makeError()) + }) +} diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 9227ff88d09..4303da1aba2 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -8,9 +8,7 @@ import { Log } from "@/util" 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 +import { KiloQuestion } from "@/kilocode/question" // kilocode_change export namespace Question { const log = Log.create({ service: "question" }) @@ -199,13 +197,7 @@ export namespace Question { 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 + yield* KiloQuestion.guardFollowup(input.sessionID, () => new RejectedError()) // kilocode_change pending.set(id, { info, deferred }) yield* bus.publish(Event.Asked, info) @@ -259,21 +251,12 @@ export namespace Question { return Array.from(pending.values(), (x) => x.info) }) - // kilocode_change start - dismiss every pending question on a session so a new - // prompt can unblock an in-flight tool waiting on user input. Mirrors - // Suggestion.dismissAll so both read the same way at the callsite. - const dismissAll = Effect.fn("Question.dismissAll")(function* (sessionID: SessionID) { - const pending = (yield* InstanceState.get(state)).pending - const matches = Array.from(pending.entries()).filter(([, entry]) => entry.info.sessionID === sessionID) - for (const [id, entry] of matches) { - pending.delete(id) - log.info("dismissed", { requestID: id }) - yield* bus.publish(Event.Rejected, { - sessionID: entry.info.sessionID, - requestID: entry.info.id, - }) - yield* Deferred.fail(entry.deferred, new RejectedError()) - } + // kilocode_change start - body lives in @/kilocode/question/KiloQuestion.makeDismissAll + const dismissAll = KiloQuestion.makeDismissAll({ + state, + publishRejected: (entry) => + bus.publish(Event.Rejected, { sessionID: entry.info.sessionID, requestID: entry.info.id }), + makeError: () => new RejectedError(), }) // kilocode_change end From 06ce7ee8668f6c2eedf0e4020709c1771e8109d8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 14:15:51 +0300 Subject: [PATCH 10/12] vscode: require double Esc to stop a turn and keep queued messages visible Pressing Escape once no longer cancels the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI, so users don't lose their queued messages to an accidental tap. --- .changeset/double-esc-abort.md | 5 ++ packages/kilo-vscode/src/KiloProvider.ts | 7 +- .../kilo-vscode/src/agent-manager/types.ts | 1 - .../kilo-vscode/src/kilo-provider/abort.ts | 18 +--- packages/kilo-vscode/tests/unit/abort.test.ts | 49 ++--------- .../tests/unit/session-abort-press.test.ts | 84 +++++++++++++++++++ .../src/components/chat/ChatView.tsx | 8 +- .../src/components/chat/PromptInput.tsx | 3 +- .../src/context/session-abort-press.ts | 65 ++++++++++++++ .../webview-ui/src/context/session.tsx | 4 - .../webview-ui/src/types/messages.ts | 1 - 11 files changed, 173 insertions(+), 72 deletions(-) create mode 100644 .changeset/double-esc-abort.md create mode 100644 packages/kilo-vscode/tests/unit/session-abort-press.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts diff --git a/.changeset/double-esc-abort.md b/.changeset/double-esc-abort.md new file mode 100644 index 00000000000..2e3658134fb --- /dev/null +++ b/.changeset/double-esc-abort.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Pressing Escape once in the Kilo Code sidebar no longer aborts the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI. Queued messages stay visible so you can see what was waiting in the queue. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 46ad590eb67..85cd37481bf 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -54,7 +54,7 @@ import { clearCommandsCache, loadCommands } from "./kilo-provider/commands" import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page" import { childID } from "./kilo-provider/task-session" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" -import { abortSession, parseQueued } from "./kilo-provider/abort" +import { abortSession } from "./kilo-provider/abort" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" import { retryable, backoff, MAX_RETRIES } from "./util/retry" @@ -613,7 +613,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } case "abort": this.cancelRetry(message.sessionID ?? "") - await this.handleAbort(message.sessionID, parseQueued(message.queuedMessageIDs)) + await this.handleAbort(message.sessionID) break case "revertSession": this.handleRevertSession(message.sessionID, message.messageID).catch((e) => @@ -2558,7 +2558,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async handleAbort(sessionID?: string, queuedMessageIDs: string[] = []): Promise { + private async handleAbort(sessionID?: string): Promise { if (!this.client) { return } @@ -2573,7 +2573,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper client: this.client, sessionID: targetSessionID, dir: this.getWorkspaceDirectory(targetSessionID), - queuedMessageIDs, }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to abort session:", error) diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 65e34a730b7..5f4846c96c7 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -623,7 +623,6 @@ interface ForkSessionIn { interface AbortIn { type: "abort" sessionID: string - queuedMessageIDs?: string[] } interface ContinueInWorktreeIn { diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index e295fea5454..8d3d00676e9 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -1,21 +1,5 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -export function parseQueued(value: unknown) { - if (!Array.isArray(value)) return [] - return value.filter((id): id is string => typeof id === "string") -} - -export async function abortSession(input: { - client: KiloClient - sessionID: string - dir: string - queuedMessageIDs: string[] -}) { +export async function abortSession(input: { client: KiloClient; sessionID: string; dir: string }) { await input.client.session.abort({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true }) - - for (const mid of new Set(input.queuedMessageIDs)) { - await input.client.session - .deleteMessage({ sessionID: input.sessionID, messageID: mid, directory: input.dir }, { throwOnError: true }) - .catch((err) => console.error("[Kilo New] KiloProvider: Failed to remove queued message:", err)) - } } diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index c9002a650c6..126333e6a4e 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { abortSession, parseQueued } from "../../src/kilo-provider/abort" +import { abortSession } from "../../src/kilo-provider/abort" function client(calls: unknown[], fail = false) { return { @@ -10,35 +10,15 @@ function client(calls: unknown[], fail = false) { if (fail) throw new Error("abort failed") return { data: true } }, - deleteMessage: async (params: unknown, opts: unknown) => { - calls.push({ type: "delete", params, opts }) - return { data: true } - }, }, } as unknown as KiloClient } -describe("parseQueued", () => { - it("keeps only string queued message ids", () => { - expect(parseQueued(["message_1", 2, null, "message_2", {}])).toEqual(["message_1", "message_2"]) - }) - - it("returns empty ids for invalid payloads", () => { - expect(parseQueued(undefined)).toEqual([]) - expect(parseQueued({ queuedMessageIDs: ["message_1"] })).toEqual([]) - }) -}) - describe("abortSession", () => { - it("aborts before removing queued follow-up messages", async () => { + it("calls session.abort with the session id and directory", async () => { const calls: unknown[] = [] - await abortSession({ - client: client(calls), - sessionID: "session_1", - dir: "/repo", - queuedMessageIDs: ["message_2", "message_3", "message_2"], - }) + await abortSession({ client: client(calls), sessionID: "session_1", dir: "/repo" }) expect(calls).toEqual([ { @@ -46,30 +26,15 @@ describe("abortSession", () => { params: { sessionID: "session_1", directory: "/repo" }, opts: { throwOnError: true }, }, - { - type: "delete", - params: { sessionID: "session_1", messageID: "message_2", directory: "/repo" }, - opts: { throwOnError: true }, - }, - { - type: "delete", - params: { sessionID: "session_1", messageID: "message_3", directory: "/repo" }, - opts: { throwOnError: true }, - }, ]) }) - it("does not remove queued messages when abort fails", async () => { + it("rejects when the abort request fails", async () => { const calls: unknown[] = [] - await expect( - abortSession({ - client: client(calls, true), - sessionID: "session_1", - dir: "/repo", - queuedMessageIDs: ["message_2"], - }), - ).rejects.toThrow("abort failed") + await expect(abortSession({ client: client(calls, true), sessionID: "session_1", dir: "/repo" })).rejects.toThrow( + "abort failed", + ) expect(calls).toEqual([ { diff --git a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts new file mode 100644 index 00000000000..44c997179d4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test" +import { createAbortPressForTest } from "../../webview-ui/src/context/session-abort-press" + +// Minimal fake timer harness: `set` returns an incrementing id and stores the +// callback; `advance` runs the callback if called. Matches the subset of timer +// behavior the helper depends on (set, clear, expiry). +function fakeTimers() { + const queue = new Map void>() + let nextId = 0 + return { + set: (fn: () => void) => { + nextId += 1 + queue.set(nextId, fn) + return nextId as unknown as ReturnType + }, + clear: (t: ReturnType) => { + queue.delete(t as unknown as number) + }, + expire: (t: ReturnType) => { + const fn = queue.get(t as unknown as number) + if (!fn) return false + queue.delete(t as unknown as number) + fn() + return true + }, + pending: () => queue.size, + } +} + +describe("session-abort-press", () => { + it("requires two presses to trigger", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + expect(gate.press()).toBe(false) + expect(gate.count).toBe(1) + expect(gate.hasTimer).toBe(true) + + expect(gate.press()).toBe(true) + expect(gate.count).toBe(0) + expect(gate.hasTimer).toBe(false) + }) + + it("resets after the window elapses", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + expect(gate.press()).toBe(false) + expect(timers.pending()).toBe(1) + + // Expire the timer — simulates the 5s window elapsing with no second press. + timers.expire(1 as unknown as ReturnType) + expect(gate.count).toBe(0) + expect(gate.hasTimer).toBe(false) + + // Next press restarts the counter from 1. + expect(gate.press()).toBe(false) + expect(gate.count).toBe(1) + }) + + it("re-arms the timer on every press", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + gate.press() + // Press again before the window closes — prior timer is cleared, new one started. + // Since this is the second press, it triggers and clears (no pending timer). + gate.press() + expect(timers.pending()).toBe(0) + }) + + it("reset() clears count and timer", () => { + const timers = fakeTimers() + const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) + + gate.press() + expect(gate.hasTimer).toBe(true) + + gate.reset() + expect(gate.count).toBe(0) + expect(gate.hasTimer).toBe(false) + expect(timers.pending()).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 7da3fa1dce4..5114c5b4419 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -19,6 +19,7 @@ import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import { useWorktreeMode } from "../../context/worktree-mode" import { useServer } from "../../context/server" +import { registerAbortPress, resetAbortPress } from "../../context/session-abort-press" import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils" interface ChatViewProps { @@ -89,10 +90,13 @@ export const ChatView: Component = (props) => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape" || session.status() === "idle" || e.defaultPrevented) return e.preventDefault() - session.abort() + if (registerAbortPress()) session.abort() } document.addEventListener("keydown", handler) - onCleanup(() => document.removeEventListener("keydown", handler)) + onCleanup(() => { + document.removeEventListener("keydown", handler) + resetAbortPress() + }) }) // Listen for "Continue in Worktree" progress messages diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index c9f6c2bec0a..6148317b54e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -17,6 +17,7 @@ import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" +import { registerAbortPress } from "../../context/session-abort-press" import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" @@ -553,7 +554,7 @@ export const PromptInput: Component = (props) => { if (e.key === "Escape" && isBusy()) { e.preventDefault() e.stopPropagation() - session.abort() + if (registerAbortPress()) session.abort() return } if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts new file mode 100644 index 00000000000..24b2c3e8bdd --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts @@ -0,0 +1,65 @@ +// Shared counter for the double-Esc-to-abort gesture. +// Mirrors the CLI's `store.interrupt` in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx: +// press increments a counter and (re)starts a 5s reset timer; the second press within the window +// triggers abort and resets. + +const WINDOW_MS = 5000 + +interface Timers { + set: (fn: () => void, ms: number) => ReturnType + clear: (t: ReturnType) => void +} + +function press(state: { count: number; timer: ReturnType | undefined }, timers: Timers): boolean { + state.count += 1 + if (state.timer) timers.clear(state.timer) + if (state.count >= 2) { + state.count = 0 + state.timer = undefined + return true + } + state.timer = timers.set(() => { + state.count = 0 + state.timer = undefined + }, WINDOW_MS) + return false +} + +const defaults: Timers = { + set: (fn, ms) => setTimeout(fn, ms), + clear: (t) => clearTimeout(t), +} + +const shared: { count: number; timer: ReturnType | undefined } = { count: 0, timer: undefined } + +// Registers an Esc press; returns true when this press is the second within the +// 5s window (and the caller should trigger abort). +export function registerAbortPress(): boolean { + return press(shared, defaults) +} + +// Resets the counter. Safe to call from anywhere (idle transitions, tests, etc.). +export function resetAbortPress(): void { + shared.count = 0 + if (shared.timer) defaults.clear(shared.timer) + shared.timer = undefined +} + +// Test-only factory: creates an isolated press-state with caller-supplied timers. +export function createAbortPressForTest(timers: Timers) { + const state = { count: 0, timer: undefined as ReturnType | undefined } + return { + press: () => press(state, timers), + reset: () => { + state.count = 0 + if (state.timer) timers.clear(state.timer) + state.timer = undefined + }, + get count() { + return state.count + }, + get hasTimer() { + return state.timer !== undefined + }, + } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 485f4ba964a..0a1936d7232 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -46,7 +46,6 @@ import { import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { resolveSessionAgent } from "./session-agent" -import { queuedUserMessageIDs } from "./session-queue" import { PartStash } from "./part-stash" import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model" @@ -1748,12 +1747,9 @@ export const SessionProvider: ParentComponent = (props) => { return } - const queuedMessageIDs = queuedUserMessageIDs(messages(), statusInfo()) - vscode.postMessage({ type: "abort", sessionID, - queuedMessageIDs, }) } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 1a29297159a..a631d4178b8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1703,7 +1703,6 @@ export interface SendMessageRequest { export interface AbortRequest { type: "abort" sessionID: string - queuedMessageIDs?: string[] } export interface RevertSessionRequest { From 25dd19c7f78b6fa41e266921d5d9bdafd30a5f83 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 14:25:29 +0300 Subject: [PATCH 11/12] fix(vscode): reset abort counter when turn ends --- .../webview-ui/src/components/chat/ChatView.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 5114c5b4419..f71ecaf6a07 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -99,6 +99,17 @@ export const ChatView: Component = (props) => { }) }) + // Reset the double-Esc counter whenever the session returns to idle so a + // single Esc press from a prior turn cannot combine with a press in the next turn. + createEffect( + on( + () => session.status() === "idle", + (isIdle) => { + if (isIdle) resetAbortPress() + }, + ), + ) + // Listen for "Continue in Worktree" progress messages { const labels: Record = { From 3d7fb31307ebeb1fe0c726a624b506bad1e6c44b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 23 Apr 2026 16:25:21 +0300 Subject: [PATCH 12/12] fix(vscode): restore single Escape to stop a running turn A single Escape in the Kilo Code sidebar stops the current turn again, the way it did before. Queued follow-up messages still stay visible after stopping so you can see what was waiting. --- .changeset/double-esc-abort.md | 5 -- .../tests/unit/session-abort-press.test.ts | 84 ------------------- .../src/components/chat/ChatView.tsx | 19 +---- .../src/components/chat/PromptInput.tsx | 3 +- .../src/context/session-abort-press.ts | 65 -------------- 5 files changed, 3 insertions(+), 173 deletions(-) delete mode 100644 .changeset/double-esc-abort.md delete mode 100644 packages/kilo-vscode/tests/unit/session-abort-press.test.ts delete mode 100644 packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts diff --git a/.changeset/double-esc-abort.md b/.changeset/double-esc-abort.md deleted file mode 100644 index 2e3658134fb..00000000000 --- a/.changeset/double-esc-abort.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Pressing Escape once in the Kilo Code sidebar no longer aborts the running turn or clears queued follow-up messages. Press Escape twice within 5 seconds to stop the current turn, matching the CLI. Queued messages stay visible so you can see what was waiting in the queue. diff --git a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts b/packages/kilo-vscode/tests/unit/session-abort-press.test.ts deleted file mode 100644 index 44c997179d4..00000000000 --- a/packages/kilo-vscode/tests/unit/session-abort-press.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { createAbortPressForTest } from "../../webview-ui/src/context/session-abort-press" - -// Minimal fake timer harness: `set` returns an incrementing id and stores the -// callback; `advance` runs the callback if called. Matches the subset of timer -// behavior the helper depends on (set, clear, expiry). -function fakeTimers() { - const queue = new Map void>() - let nextId = 0 - return { - set: (fn: () => void) => { - nextId += 1 - queue.set(nextId, fn) - return nextId as unknown as ReturnType - }, - clear: (t: ReturnType) => { - queue.delete(t as unknown as number) - }, - expire: (t: ReturnType) => { - const fn = queue.get(t as unknown as number) - if (!fn) return false - queue.delete(t as unknown as number) - fn() - return true - }, - pending: () => queue.size, - } -} - -describe("session-abort-press", () => { - it("requires two presses to trigger", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - expect(gate.press()).toBe(false) - expect(gate.count).toBe(1) - expect(gate.hasTimer).toBe(true) - - expect(gate.press()).toBe(true) - expect(gate.count).toBe(0) - expect(gate.hasTimer).toBe(false) - }) - - it("resets after the window elapses", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - expect(gate.press()).toBe(false) - expect(timers.pending()).toBe(1) - - // Expire the timer — simulates the 5s window elapsing with no second press. - timers.expire(1 as unknown as ReturnType) - expect(gate.count).toBe(0) - expect(gate.hasTimer).toBe(false) - - // Next press restarts the counter from 1. - expect(gate.press()).toBe(false) - expect(gate.count).toBe(1) - }) - - it("re-arms the timer on every press", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - gate.press() - // Press again before the window closes — prior timer is cleared, new one started. - // Since this is the second press, it triggers and clears (no pending timer). - gate.press() - expect(timers.pending()).toBe(0) - }) - - it("reset() clears count and timer", () => { - const timers = fakeTimers() - const gate = createAbortPressForTest({ set: timers.set, clear: timers.clear }) - - gate.press() - expect(gate.hasTimer).toBe(true) - - gate.reset() - expect(gate.count).toBe(0) - expect(gate.hasTimer).toBe(false) - expect(timers.pending()).toBe(0) - }) -}) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index f71ecaf6a07..7da3fa1dce4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -19,7 +19,6 @@ import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import { useWorktreeMode } from "../../context/worktree-mode" import { useServer } from "../../context/server" -import { registerAbortPress, resetAbortPress } from "../../context/session-abort-press" import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils" interface ChatViewProps { @@ -90,26 +89,12 @@ export const ChatView: Component = (props) => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape" || session.status() === "idle" || e.defaultPrevented) return e.preventDefault() - if (registerAbortPress()) session.abort() + session.abort() } document.addEventListener("keydown", handler) - onCleanup(() => { - document.removeEventListener("keydown", handler) - resetAbortPress() - }) + onCleanup(() => document.removeEventListener("keydown", handler)) }) - // Reset the double-Esc counter whenever the session returns to idle so a - // single Esc press from a prior turn cannot combine with a press in the next turn. - createEffect( - on( - () => session.status() === "idle", - (isIdle) => { - if (isIdle) resetAbortPress() - }, - ), - ) - // Listen for "Continue in Worktree" progress messages { const labels: Record = { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 6148317b54e..c9f6c2bec0a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -17,7 +17,6 @@ import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { registerAbortPress } from "../../context/session-abort-press" import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" @@ -554,7 +553,7 @@ export const PromptInput: Component = (props) => { if (e.key === "Escape" && isBusy()) { e.preventDefault() e.stopPropagation() - if (registerAbortPress()) session.abort() + session.abort() return } if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts b/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts deleted file mode 100644 index 24b2c3e8bdd..00000000000 --- a/packages/kilo-vscode/webview-ui/src/context/session-abort-press.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Shared counter for the double-Esc-to-abort gesture. -// Mirrors the CLI's `store.interrupt` in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx: -// press increments a counter and (re)starts a 5s reset timer; the second press within the window -// triggers abort and resets. - -const WINDOW_MS = 5000 - -interface Timers { - set: (fn: () => void, ms: number) => ReturnType - clear: (t: ReturnType) => void -} - -function press(state: { count: number; timer: ReturnType | undefined }, timers: Timers): boolean { - state.count += 1 - if (state.timer) timers.clear(state.timer) - if (state.count >= 2) { - state.count = 0 - state.timer = undefined - return true - } - state.timer = timers.set(() => { - state.count = 0 - state.timer = undefined - }, WINDOW_MS) - return false -} - -const defaults: Timers = { - set: (fn, ms) => setTimeout(fn, ms), - clear: (t) => clearTimeout(t), -} - -const shared: { count: number; timer: ReturnType | undefined } = { count: 0, timer: undefined } - -// Registers an Esc press; returns true when this press is the second within the -// 5s window (and the caller should trigger abort). -export function registerAbortPress(): boolean { - return press(shared, defaults) -} - -// Resets the counter. Safe to call from anywhere (idle transitions, tests, etc.). -export function resetAbortPress(): void { - shared.count = 0 - if (shared.timer) defaults.clear(shared.timer) - shared.timer = undefined -} - -// Test-only factory: creates an isolated press-state with caller-supplied timers. -export function createAbortPressForTest(timers: Timers) { - const state = { count: 0, timer: undefined as ReturnType | undefined } - return { - press: () => press(state, timers), - reset: () => { - state.count = 0 - if (state.timer) timers.clear(state.timer) - state.timer = undefined - }, - get count() { - return state.count - }, - get hasTimer() { - return state.timer !== undefined - }, - } -}