mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
fix(vscode): hide manual interruption warning
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Stop manually aborted turns without briefly showing an interruption warning.
|
||||
@@ -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<void> {
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff"
|
||||
|
||||
export interface ContinueContext {
|
||||
root: string
|
||||
getClient: () => KiloClient
|
||||
connection: {
|
||||
getClient: () => KiloClient
|
||||
runExplicitAbort: <T>(sessionId: string, directory: string, action: () => Promise<T>) => Promise<T>
|
||||
}
|
||||
createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{
|
||||
worktree: { id: string }
|
||||
result: CreateWorktreeResult
|
||||
@@ -30,10 +33,13 @@ export type StepResult<T> = { 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<void> {
|
||||
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<StepResult<Session>> {
|
||||
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" }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SSEEventListener> = new Set()
|
||||
private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>()
|
||||
private readonly explicitAborts = new ExplicitAbortState()
|
||||
private readonly stateListeners: Set<StateListener> = new Set()
|
||||
private readonly notificationDismissListeners: Set<NotificationDismissListener> = new Set()
|
||||
private readonly languageChangeListeners: Set<LanguageChangeListener> = 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<T>(sessionID: string, directory: string, action: () => Promise<T>): Promise<T> {
|
||||
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)
|
||||
|
||||
@@ -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<number>; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean }
|
||||
|
||||
export class ExplicitAbortState {
|
||||
private readonly active = new Set<string>()
|
||||
private readonly states = new Map<string, State>()
|
||||
private readonly generations = new Map<string, number>()
|
||||
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<SSEPayload, { type: "session.status" }>, 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)}`
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -72,8 +72,11 @@ function result(path: string): CreateWorktreeResult {
|
||||
function ctx(overrides: Partial<ContinueContext> = {}): 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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -191,6 +191,8 @@ function createConnection(client: ReturnType<typeof createClient>) {
|
||||
},
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user