From 8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 29 Jul 2026 12:31:47 +0200 Subject: [PATCH] fix(vscode): stop flashing interruption warning on queued follow-up handoff A prompt sent while a session is running queues behind the active turn, which breaks out of its loop after the current LLM step drains. That handoff was recorded with close reason "interrupted", so the webview rendered the yellow "Turn interrupted." card during the brief idle window before the queued turn started. The handoff now closes with a dedicated "superseded" reason instead. Clients extend their close-reason unions and the webview suppresses the terminal warning card for superseded turns; memory digests still treat the cut-short turn as interrupted, and real user interruptions are unchanged. --- .changeset/superseded-turn-close.md | 6 + .../kilo-vscode/src/kilo-provider-utils.ts | 2 +- .../tests/unit/session-outcome.test.ts | 7 + .../webview-ui/src/context/session-outcome.ts | 3 + .../webview-ui/src/types/messages/sessions.ts | 2 +- packages/opencode/src/kilocode/memory/turn.ts | 4 +- .../opencode/src/kilocode/session/event.ts | 5 +- packages/opencode/src/session/prompt.ts | 6 +- .../kilocode/session-prompt-queue.test.ts | 90 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 290 ++++++++++-------- 11 files changed, 284 insertions(+), 133 deletions(-) create mode 100644 .changeset/superseded-turn-close.md diff --git a/.changeset/superseded-turn-close.md b/.changeset/superseded-turn-close.md new file mode 100644 index 0000000000..1e966a961a --- /dev/null +++ b/.changeset/superseded-turn-close.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions. diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index e5dbd40e44..c9825cb771 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -408,7 +408,7 @@ export type WebviewMessage = message: Record } | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } - | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" } + | { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" } | { type: "permissionRequest" permission: { diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 8dd06b6dd6..93b8475e5f 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -138,6 +138,13 @@ describe("terminal", () => { ).toBe("error") }) + it("hides superseded turns that handed off to a queued follow-up", () => { + expect( + terminal({ reason: "superseded", messages: [message("tool-calls")], todos: [todo("pending")] }), + ).toBeUndefined() + expect(terminal({ reason: "superseded", messages: [message("unknown")], todos: [] })).toBeUndefined() + }) + it("reports only the latest assistant finish reason", () => { const user: Message = { id: "u1", sessionID: "s1", role: "user", createdAt: new Date(1).toISOString() } expect(terminal({ reason: "completed", messages: [message("length"), user], todos: [] })?.finish).toBeUndefined() diff --git a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts index e17f548ef8..eaf9b6bf76 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts @@ -41,6 +41,9 @@ function identifiers( export function terminal(input: Input): TerminalState | undefined { if (!input.reason) return undefined + // A superseded turn handed off to a queued follow-up; it is not a premature + // stop, and the follow-up turn closes with its own reason afterwards. + if (input.reason === "superseded") return undefined const last = input.messages[input.messages.length - 1] const finish = last?.role === "assistant" ? last.finish : undefined const ids = identifiers(last, input.parts) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts index 90a5733027..1322349e28 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/sessions.ts @@ -3,7 +3,7 @@ import type { Part, TokenUsage } from "./parts" export type SessionModelUsage = KilocodeSessionModelUsageResponse -export type SessionCloseReason = "completed" | "error" | "interrupted" +export type SessionCloseReason = "completed" | "error" | "interrupted" | "superseded" // Message structure (simplified for webview) export interface Message { diff --git a/packages/opencode/src/kilocode/memory/turn.ts b/packages/opencode/src/kilocode/memory/turn.ts index c41068c0e3..d9fdc79ae7 100644 --- a/packages/opencode/src/kilocode/memory/turn.ts +++ b/packages/opencode/src/kilocode/memory/turn.ts @@ -78,7 +78,9 @@ export namespace MemoryLifecycle { if (!enabled) return yield* MemoryTurn.close({ sessionID: evt.properties.sessionID, - reason: evt.properties.reason, + // A superseded turn handed off to a queued follow-up after draining + // its step; for digest purposes it was cut short like an interrupt. + reason: evt.properties.reason === "superseded" ? "interrupted" : evt.properties.reason, sessions: input.sessions, summary: input.summary, provider: input.provider, diff --git a/packages/opencode/src/kilocode/session/event.ts b/packages/opencode/src/kilocode/session/event.ts index 47025588a8..e8a851211e 100644 --- a/packages/opencode/src/kilocode/session/event.ts +++ b/packages/opencode/src/kilocode/session/event.ts @@ -2,7 +2,10 @@ import { BusEvent } from "@/bus/bus-event" import { MessageID, SessionID } from "@/session/schema" import { Schema } from "effect" -const CloseReason = Schema.Literals(["completed", "error", "interrupted"]) +// "superseded": the turn handed off to a queued follow-up after draining its +// current step. Distinct from "interrupted" so clients do not surface a +// premature-stop warning for a deliberate queue handoff. +const CloseReason = Schema.Literals(["completed", "error", "interrupted", "superseded"]) export const KiloSessionEvent = { TurnOpen: BusEvent.define( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f1520cc2e6..7bee943356 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1848,9 +1848,11 @@ export const layer = Layer.effect( // 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. + // the time we get here, so nothing is cut off. The close reason is + // "superseded", not "interrupted": this is a deliberate queue handoff, + // not a premature stop, so clients must not flash an interruption warning. if (KiloSessionPromptQueue.hasFollowup(sessionID)) { - closeReasons.set(sessionID, "interrupted") + closeReasons.set(sessionID, "superseded") return "break" as const } // kilocode_change end diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index dd5f90a017..d6a6178802 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -549,6 +549,96 @@ describe("session prompt queue", () => { } }) + test("closes a queued-handoff turn as superseded, not interrupted", async () => { + const ready = Promise.withResolvers() + const release = Promise.withResolvers() + const server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + + // Hold every stream open until the follow-up prompt is queued, so + // runLoop deterministically takes the hasFollowup break once its + // current step drains. Forked title/summary calls get held too; they + // are Effect.ignore'd and drain once released. + ready.resolve() + const stream = reply({ text: "reply", wait: release.promise }) + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify(providerCfg(server.url.origin))) + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async () => + scoped(tmp.path, async (prompt) => { + const closed: KiloSession.CloseReason[] = [] + const unsubscribe = Bus.subscribe(KiloSession.Event.TurnClose, (event) => { + closed.push(event.properties.reason) + }) + + const session = await sessions.create({ title: "Superseded close reason" }) + const first = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "first prompt" }], + }), + ) + + // A request reaching the mock implies the turn loop is running + // (forked title/summary calls fire from step 1 of the loop). + await ready.promise + const second = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "second prompt" }], + }), + ) + + // Wait until the follow-up is actually queued behind the in-flight + // turn, then let the first stream drain so runLoop hands off. + await Effect.runPromise( + pollWithTimeout( + Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(session.id) ? (true as const) : undefined)), + "follow-up prompt never queued behind the in-flight turn", + "3 seconds", + ), + ) + release.resolve() + + expect((await first).info.role).toBe("assistant") + expect((await second).info.role).toBe("assistant") + // Bus delivery is a microtask chain; flush a macrotask so the last + // TurnClose callback lands before asserting. + await new Promise((resolve) => setTimeout(resolve, 0)) + unsubscribe() + + expect(closed).toHaveLength(2) + // The first turn drained its stream cleanly and handed off to the + // queued follow-up; it must not look like a user interruption to + // clients (they flash a "Turn interrupted" warning on that reason). + expect(closed[0]).toBe("superseded") + expect(closed[1]).toBe("completed") + }), + }) + } finally { + server.stop(true) + } + }, 20_000) + test("bridges legacy instance context for prompts after a completed turn", async () => { const calls: number[] = [] const server = Bun.serve({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 61953cc620..f5c44c1930 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3557,7 +3557,7 @@ export type EventSessionTurnClose = { properties: { sessionID: string parentID?: string - reason: "completed" | "error" | "interrupted" + reason: "completed" | "error" | "interrupted" | "superseded" } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 53526b5f00..4b5d75a7d5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -24496,6 +24496,15 @@ { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, + { + "$ref": "#/components/schemas/EventSessionTurnOpen" + }, + { + "$ref": "#/components/schemas/EventSessionTurnClose" + }, + { + "$ref": "#/components/schemas/EventSessionQueueChanged" + }, { "$ref": "#/components/schemas/EventSessionNetworkAsked" }, @@ -24523,12 +24532,6 @@ { "$ref": "#/components/schemas/EventInteractive_terminalDeleted" }, - { - "$ref": "#/components/schemas/EventSessionTurnOpen" - }, - { - "$ref": "#/components/schemas/EventSessionTurnClose" - }, { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, @@ -24557,10 +24560,10 @@ "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { "$ref": "#/components/schemas/EventMemoryStatus1" @@ -24818,10 +24821,10 @@ "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventLspUpdated" }, { "$ref": "#/components/schemas/EventWorkspaceReady" @@ -26838,7 +26841,7 @@ "type": "object", "properties": { "start": { - "type": "number", + "type": "integer", "minimum": 0 } }, @@ -26914,11 +26917,11 @@ "type": "object", "properties": { "start": { - "type": "number", + "type": "integer", "minimum": 0 }, "end": { - "type": "number", + "type": "integer", "minimum": 0 }, "elapsed": { @@ -27686,6 +27689,15 @@ { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, + { + "$ref": "#/components/schemas/EventSessionTurnOpen" + }, + { + "$ref": "#/components/schemas/EventSessionTurnClose" + }, + { + "$ref": "#/components/schemas/EventSessionQueueChanged" + }, { "$ref": "#/components/schemas/EventSessionNetworkAsked" }, @@ -27713,12 +27725,6 @@ { "$ref": "#/components/schemas/EventInteractive_terminalDeleted" }, - { - "$ref": "#/components/schemas/EventSessionTurnOpen" - }, - { - "$ref": "#/components/schemas/EventSessionTurnClose" - }, { "$ref": "#/components/schemas/EventSandboxStatusChanged" }, @@ -27747,10 +27753,10 @@ "$ref": "#/components/schemas/EventKilocodeNotebookCancelled" }, { - "$ref": "#/components/schemas/EventLspClientDiagnostics" + "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" }, { - "$ref": "#/components/schemas/EventKilo-sessionsRemote-status-changed" + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, { "$ref": "#/components/schemas/EventMemoryStatus" @@ -28008,10 +28014,10 @@ "$ref": "#/components/schemas/EventProjectUpdated" }, { - "$ref": "#/components/schemas/EventLspUpdated" + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, { - "$ref": "#/components/schemas/EventVcsBranchUpdated" + "$ref": "#/components/schemas/EventLspUpdated" }, { "$ref": "#/components/schemas/EventWorkspaceReady" @@ -35218,6 +35224,96 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionTurnOpen": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.turn.open"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionTurnClose": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.turn.close"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "parentID": { + "type": "string", + "pattern": "^ses" + }, + "reason": { + "type": "string", + "enum": ["completed", "error", "interrupted", "superseded"] + } + }, + "required": ["sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionQueueChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.queue.changed"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "queued": { + "type": "array", + "items": { + "type": "string", + "pattern": "^msg" + } + } + }, + "required": ["sessionID", "queued"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNetworkAsked": { "type": "object", "properties": { @@ -35470,64 +35566,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionTurnOpen": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.turn.open"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventSessionTurnClose": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["session.turn.close"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "parentID": { - "type": "string", - "pattern": "^ses" - }, - "reason": { - "type": "string", - "enum": ["completed", "error", "interrupted"] - } - }, - "required": ["sessionID", "reason"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSandboxStatusChanged": { "type": "object", "properties": { @@ -35837,33 +35875,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspClientDiagnostics": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.client.diagnostics"] - }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventKilo-sessionsRemote-status-changed": { "type": "object", "properties": { @@ -35891,6 +35902,33 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventMemoryStatus": { "type": "object", "properties": { @@ -39854,24 +39892,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventLspUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["lsp.updated"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventVcsBranchUpdated": { "type": "object", "properties": { @@ -39895,6 +39915,24 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventWorkspaceReady": { "type": "object", "properties": {