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": {