diff --git a/.changeset/quiet-manual-interruptions.md b/.changeset/quiet-manual-interruptions.md new file mode 100644 index 0000000000..07927ead3e --- /dev/null +++ b/.changeset/quiet-manual-interruptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop manually aborted turns without briefly showing an interruption warning. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 903ea4101f..24f0ddaa40 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4036,7 +4036,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.cancelRetry(sid) const client = this.client if (!client) return Promise.resolve(false) - return this.aborts.stop(client, sid, this.getWorkspaceDirectory(sid)) + const directory = this.getWorkspaceDirectory(sid) + const dirs = this.aborts.directories(sid, directory) + const ids = new Map(dirs.map((dir) => [dir, this.connectionService.beginExplicitAbort(sid, dir)])) + return this.aborts.stop(client, sid, directory, dirs).then((result) => { + for (const attempt of result.attempts) { + this.connectionService.finishExplicitAbort(sid, attempt.dir, ids.get(attempt.dir)!, attempt.aborted) + } + return result.complete + }) } private async handleAbort(sessionID?: string): Promise { @@ -4044,7 +4052,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!sid || !(await this.stopSession(sid))) return this.sessionStatusMap.set(sid, "idle") this.streams.flush(sid) - this.postMessage({ type: "sessionTurnClosed", sessionID: sid, reason: "interrupted" }) this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d4abec3fe9..ce865e0d28 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1804,7 +1804,7 @@ export class AgentManagerProvider implements Disposable { await continueInWorktree( { root, - getClient: () => this.connectionService.getClient(), + connection: this.connectionService, createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id), cleanupWorktree: async (id) => { diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index 77a51d5031..676a8cbfa6 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string - getClient: () => KiloClient + connection: { + getClient: () => KiloClient + runExplicitAbort: (sessionId: string, directory: string, action: () => Promise) => Promise + } createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{ worktree: { id: string } result: CreateWorktreeResult @@ -30,10 +33,13 @@ export type StepResult = { ok: true; value: T } | { ok: false; error: string /** Abort a running session. Best-effort — failures are logged but not fatal. */ export async function abortSession(ctx: ContinueContext, sessionId: string): Promise { try { - const client = ctx.getClient() - await client.session.abort({ sessionID: sessionId }).catch((err) => { - ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) - }) + await ctx.connection + .runExplicitAbort(sessionId, ctx.root, async () => { + await ctx.connection.getClient().session.abort({ sessionID: sessionId }, { throwOnError: true }) + }) + .catch((err) => { + ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) + }) } catch (err) { ctx.log("Client not available for abort, continuing:", getErrorMessage(err)) } @@ -96,7 +102,7 @@ async function rollback( export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise> { let client: KiloClient try { - client = ctx.getClient() + client = ctx.connection.getClient() } catch (err) { ctx.log("Client not available for session fork:", getErrorMessage(err)) return { ok: false, error: "Not connected to CLI backend" } diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 7bb355b5b2..15f7c7f48a 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -27,20 +27,27 @@ export class SessionAbort { this.observe(sessionID, status, dir) } - async stop(client: KiloClient, sessionID: string, fallback: string) { - const known = this.active.has(sessionID) + directories(sessionID: string, fallback: string) { const dirs = [...(this.active.get(sessionID) ?? [])] if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + return dirs + } + + async stop(client: KiloClient, sessionID: string, fallback: string, dirs = this.directories(sessionID, fallback)) { + const known = this.active.has(sessionID) const results = await Promise.allSettled(dirs.map((dir) => abortSession({ client, sessionID, dir }))) const failures = results.flatMap((result, index) => result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], ) if (failures.length > 0) { console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) - return false + return { + complete: false, + attempts: results.map((result, index) => ({ dir: dirs[index], aborted: result.status === "fulfilled" })), + } } if (known) this.active.delete(sessionID) - return known + return { complete: known, attempts: dirs.map((dir) => ({ dir, aborted: true })) } } dispose(dir: string) { diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index 262b2d64bf..9ec1e5ccf4 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import * as vscode from "vscode" import { KiloConnectionService } from "./connection-service" +import type { SSEPayload } from "./sdk-sse-adapter" function state(value: boolean) { return { @@ -39,6 +40,74 @@ describe("KiloConnectionService clients", () => { }) }) +describe("KiloConnectionService explicit aborts", () => { + const close = { + id: "event-close", + type: "session.turn.close", + properties: { sessionID: "session", reason: "interrupted" }, + } as SSEPayload + const status = { + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + } as SSEPayload + + test("suppresses a successful explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, true) + + expect(first).toEqual([]) + expect(second).toEqual([]) + expect(raw).toEqual([close]) + }) + + test("replays a failed explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, false) + + expect(first).toEqual([close]) + expect(second).toEqual([close]) + expect(raw).toEqual([close]) + }) +}) + describe("KiloConnectionService viewed sessions", () => { test("keeps Agent Manager sessions when sidebar visibility changes during a flush", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index d202eeedba..79398b2a52 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -5,6 +5,7 @@ import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" +import { ExplicitAbortState } from "./explicit-abort" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEPayload, directory?: string) => void @@ -96,6 +97,8 @@ export class KiloConnectionService { private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() + private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>() + private readonly explicitAborts = new ExplicitAbortState() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -276,13 +279,34 @@ export class KiloConnectionService { * Subscribe to SSE events with a filter. The filter runs for every incoming SSE event. */ onEventFiltered(filter: SSEEventFilter, listener: SSEEventListener): () => void { - const wrapped: SSEEventListener = (event, directory) => { - if (!filter(event, directory)) { - return - } - listener(event, directory) + const entry = { filter, listener } + this.filteredListeners.add(entry) + return () => { + this.filteredListeners.delete(entry) } - return this.onEvent(wrapped) + } + + beginExplicitAbort(sessionID: string, directory: string): number | undefined { + return this.explicitAborts.begin(sessionID, directory) + } + + finishExplicitAbort(sessionID: string, directory: string, id: number | undefined, stopped: boolean): void { + for (const item of this.explicitAborts.finish(sessionID, directory, id, stopped)) + this.broadcastFiltered(item.event, item.directory) + } + + async runExplicitAbort(sessionID: string, directory: string, action: () => Promise): Promise { + const id = this.beginExplicitAbort(sessionID, directory) + return action().then( + (result) => { + this.finishExplicitAbort(sessionID, directory, id, true) + return result + }, + (error) => { + this.finishExplicitAbort(sessionID, directory, id, false) + throw error + }, + ) } /** @@ -305,6 +329,7 @@ export class KiloConnectionService { * id after external (CLI/TUI/cascade) deletes arrive via SSE. */ pruneSession(sessionId: string): void { + this.explicitAborts.remove(sessionId) for (const [mid, sid] of this.messageSessionIdsByMessageId) { if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid) } @@ -681,6 +706,8 @@ export class KiloConnectionService { this.sseClient?.dispose() this.serverManager.dispose() this.eventListeners.clear() + this.filteredListeners.clear() + this.explicitAborts.clear() this.stateListeners.clear() this.notificationDismissListeners.clear() this.profileChangeListeners.clear() @@ -780,6 +807,7 @@ export class KiloConnectionService { this.stopHealthPoll() this.stopCheckin() const sse = this.sseClient + this.explicitAborts.clear() this.sseClient = null sse?.disconnect() this.client = null @@ -837,11 +865,7 @@ export class KiloConnectionService { // Wire SSE events → broadcast to all registered listeners sse.onEvent((event, directory) => { if (this.sseClient !== sse) return - this.handlePermissionEvent(event, directory) - this.handleQuestionEvent(event, directory) - for (const listener of this.eventListeners) { - listener(event, directory) - } + this.broadcast(event, directory) }) sse.onError((error) => { @@ -887,6 +911,20 @@ export class KiloConnectionService { this.startHealthPoll(config.baseUrl, config.password) } + private broadcast(event: SSEPayload, directory?: string): void { + this.handlePermissionEvent(event, directory) + this.handleQuestionEvent(event, directory) + for (const listener of this.eventListeners) listener(event, directory) + if (!this.explicitAborts.event(event, directory)) return + this.broadcastFiltered(event, directory) + } + + private broadcastFiltered(event: SSEPayload, directory?: string): void { + for (const entry of this.filteredListeners) { + if (entry.filter(event, directory)) entry.listener(event, directory) + } + } + private startCheckin(): void { this.stopCheckin() this.checkinTimer = setInterval(() => this.flushViewed(), 60_000) diff --git a/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts new file mode 100644 index 0000000000..b86e9121a2 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts @@ -0,0 +1,112 @@ +import path from "node:path" +import type { SSEPayload } from "./sdk-sse-adapter" + +type Buffered = { event: SSEPayload; directory?: string } +type State = { attempts: Set; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean } + +export class ExplicitAbortState { + private readonly active = new Set() + private readonly states = new Map() + private readonly generations = new Map() + private next = 0 + + begin(sessionID: string, directory: string): number | undefined { + const key = scope(sessionID, directory) + if (!this.active.has(key)) return + const id = ++this.next + const state = this.states.get(key) ?? { + attempts: new Set(), + stopped: false, + buffered: [], + generation: this.generations.get(key) ?? 0, + idle: false, + } + state.attempts.add(id) + this.states.set(key, state) + return id + } + + finish(sessionID: string, directory: string, id: number | undefined, stopped: boolean): Buffered[] { + if (id === undefined) return [] + const key = scope(sessionID, directory) + const state = this.states.get(key) + if (!state || !state.attempts.delete(id)) return [] + if (stopped) { + state.stopped = true + state.buffered = [] + return [] + } + if (state.stopped || state.attempts.size > 0) return [] + this.states.delete(key) + return state.buffered + } + + event(event: SSEPayload, directory?: string): boolean { + if (event.type === "session.status" && directory) return this.status(event, directory) + if (event.type === "session.turn.open") return this.open(event.properties.sessionID, directory) + if (event.type !== "session.turn.close") return true + const keys = this.keys(event.properties.sessionID, directory).filter((key) => this.states.has(key)) + if (keys.length !== 1) return true + const key = keys[0] + const state = this.states.get(key) + if (!state) return true + if (state.generation !== (this.generations.get(key) ?? 0) || event.properties.reason !== "interrupted") { + this.states.delete(key) + return true + } + if (state.stopped) return false + if (state.attempts.size === 0) { + this.states.delete(key) + return true + } + state.buffered.push({ event, directory }) + return false + } + + private status(event: Extract, directory: string) { + const key = scope(event.properties.sessionID, directory) + const state = this.states.get(key) + if (event.properties.status.type === "idle") { + this.active.delete(key) + if (state) state.idle = true + return true + } + this.active.add(key) + if (state?.idle) this.states.delete(key) + return true + } + + private open(sessionID: string, directory?: string) { + for (const key of this.keys(sessionID, directory)) { + this.generations.set(key, (this.generations.get(key) ?? 0) + 1) + this.states.delete(key) + } + return true + } + + clear() { + this.active.clear() + this.states.clear() + this.generations.clear() + } + + remove(sessionID: string) { + for (const key of this.keys(sessionID)) { + this.active.delete(key) + this.states.delete(key) + this.generations.delete(key) + } + } + + private keys(sessionID: string, directory?: string): string[] { + if (directory) return [scope(sessionID, directory)] + const prefix = `${sessionID}\0` + return [...new Set([...this.active, ...this.states.keys(), ...this.generations.keys()])].filter((key) => + key.startsWith(prefix), + ) + } +} + +function scope(sessionID: string, directory: string) { + return `${sessionID}\0${path.resolve(directory)}` +} diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index e971a1211b..af80429270 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -20,7 +20,13 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: true, + attempts: [ + { dir: "/repo", aborted: true }, + { dir: "/repo/worktree", aborted: true }, + ], + }) expect(calls).toEqual([ { type: "abort", @@ -41,7 +47,10 @@ describe("SessionAbort", () => { aborts.observe("session_1", "busy", "/repo") aborts.observe("session_1", "idle", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(false) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: false, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toEqual([ { type: "abort", @@ -56,9 +65,23 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo/worktree") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toEqual({ + complete: true, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toHaveLength(1) }) + + it("reports a failed HTTP abort separately from ownership", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + + expect(await aborts.stop(client(calls, true), "session_1", "/repo")).toEqual({ + complete: false, + attempts: [{ dir: "/repo", aborted: false }], + }) + }) }) describe("abortSession", () => { diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index 86d341b938..52c4e33748 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -72,8 +72,11 @@ function result(path: string): CreateWorktreeResult { function ctx(overrides: Partial = {}): ContinueContext { return { root: "/tmp/test", - getClient: () => { - throw new Error("no client") + connection: { + getClient: () => { + throw new Error("no client") + }, + runExplicitAbort: async (_sessionId, _directory, action) => action(), }, createWorktreeOnDisk: async () => null, runSetupScript: async () => {}, @@ -98,10 +101,12 @@ describe("continue-in-worktree steps", () => { it("does not throw when abort rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { abort: () => Promise.reject(new Error("fail")) }, - }) as never, + connection: { + getClient: () => ({ session: { abort: async () => undefined } }) as never, + runExplicitAbort: async () => { + throw new Error("fail") + }, + }, }) await abortSession(c, "session-1") }) @@ -109,15 +114,17 @@ describe("continue-in-worktree steps", () => { it("calls abort on the client", async () => { let called = false const c = ctx({ - getClient: () => - ({ - session: { - abort: () => { - called = true - return Promise.resolve() + connection: { + getClient: () => + ({ + session: { + abort: async () => { + called = true + }, }, - }, - }) as never, + }) as never, + runExplicitAbort: async (_sessionId, _directory, action) => action(), + }, }) await abortSession(c, "session-1") expect(called).toBe(true) @@ -134,10 +141,13 @@ describe("continue-in-worktree steps", () => { it("returns error when fork rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.reject(new Error("fork failed")) }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.reject(new Error("fork failed")) }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(false) @@ -148,10 +158,13 @@ describe("continue-in-worktree steps", () => { const forked = session("forked-1") const promptAsync = mock(async () => ({})) const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(true) @@ -215,7 +228,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => api, + connection: { getClient: () => api, runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value @@ -246,7 +259,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => client(), + connection: { getClient: () => client(), runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value diff --git a/packages/kilo-vscode/tests/unit/explicit-abort.test.ts b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts new file mode 100644 index 0000000000..6894478709 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { ExplicitAbortState } from "../../src/services/cli-backend/explicit-abort" + +const open = (sessionID = "session") => + ({ id: "event-open", type: "session.turn.open", properties: { sessionID } }) as SSEPayload + +const status = (type: "idle" | "busy", sessionID = "session") => + ({ type: "session.status", properties: { sessionID, status: { type } } }) as SSEPayload + +const close = (reason: "completed" | "interrupted", sessionID = "session") => + ({ id: `event-${reason}`, type: "session.turn.close", properties: { sessionID, reason } }) as SSEPayload + +describe("explicit abort state", () => { + it("does not suppress an unexpected interruption", () => { + const state = new ExplicitAbortState() + + expect(state.event(close("interrupted"))).toBe(true) + }) + + it("drops an interrupted close after an explicit abort succeeds", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("drops an interrupted close that arrives after abort success", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + }) + + it("replays an interrupted close when the abort fails", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + const event = close("interrupted") + state.event(event, "/repo") + + expect(state.finish("session", "/repo", id, false)).toEqual([{ event, directory: "/repo" }]) + }) + + it("never suppresses a completed close", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + state.begin("session", "/repo") + + expect(state.event(close("completed"))).toBe(true) + }) + + it("waits for concurrent abort attempts before replaying", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const first = state.begin("session", "/repo") + const second = state.begin("session", "/repo") + state.event(close("interrupted"), "/repo") + + expect(state.finish("session", "/repo", first, false)).toEqual([]) + expect(state.finish("session", "/repo", second, true)).toEqual([]) + }) + + it("allows a later real interruption in the same session", () => { + const state = new ExplicitAbortState() + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + expect(state.event(close("interrupted"), "/repo")).toBe(false) + state.event(status("idle"), "/repo") + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears a pending abort when a new turn opens", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(open(), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("isolates identical session ids by directory", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo/a") + const id = state.begin("session", "/repo/a") + state.finish("session", "/repo/a", id, true) + + expect(state.event(close("interrupted"), "/repo/b")).toBe(true) + expect(state.event(close("interrupted"), "/repo/a")).toBe(false) + }) + + it("does not mark an already idle session", () => { + const state = new ExplicitAbortState() + state.event(status("idle"), "/repo") + + expect(state.begin("session", "/repo")).toBeUndefined() + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears suppression on a later busy status without turn-open", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("does not carry a pending abort into a new busy turn", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 58de510b24..24be2aaf25 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -191,6 +191,8 @@ function createConnection(client: ReturnType) { }, connect: async () => {}, getClient: () => client, + beginExplicitAbort: () => 1 as number | undefined, + finishExplicitAbort: () => undefined, onEventFiltered: () => () => undefined, onStateChange: (_l: (s: State) => void) => () => undefined, onNotificationDismissed: () => () => undefined, @@ -1238,7 +1240,7 @@ describe("KiloProvider.handleLoadMessages / slim payload", () => { expect(client.aborted).toContainEqual({ sessionID: "s1", directory: "/repo" }) expect(sent).toContainEqual({ type: "sessionCostAlertResolved", sessionID: "s1", limit: 1 }) - expect(sent).toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) + expect(sent).not.toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) }) it("strips transcript-only metadata before posting messages to the webview", async () => {