diff --git a/.changeset/idle-stopped-sessions.md b/.changeset/idle-stopped-sessions.md new file mode 100644 index 00000000000..8abe303adbf --- /dev/null +++ b/.changeset/idle-stopped-sessions.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Show deliberately stopped agent sessions as idle instead of finished. diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 9bf00ea1aed..9fc7ea47c56 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -400,6 +400,19 @@ try { await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) await check("root", "idle") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + value.abort() + assert.deepEqual(sent.at(-1), { type: "abort", sessionID: "root" }) + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "interrupted" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "idle") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await emit({ type: "sessionTurnClosed", sessionID: "root", reason: "completed" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + await check("root", "done") + await emit({ type: "sessionStatus", sessionID: "root", status: "busy" }) + await emit({ type: "sessionStatus", sessionID: "root", status: "idle" }) + value.setCurrentSessionID("background") await emit({ type: "sessionStatus", sessionID: "background", status: "busy" }) await check("background", "busy") diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index be85062ae1a..47de0add498 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1925,7 +1925,14 @@ export const layer = Layer.effect( return yield* Effect.onExit( state.ensureRunning( input.sessionID, - lastAssistant(input.sessionID).pipe(Effect.orDie), + lastAssistant(input.sessionID).pipe( + Effect.tap(() => + Effect.sync(() => { + if (!closeReasons.has(input.sessionID)) closeReasons.set(input.sessionID, "interrupted") + }), + ), + Effect.orDie, + ), runLoop(input).pipe(Effect.orDie), ), // kilocode_change Effect.fnUntraced(function* (exit) { diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 58afffcf437..d8db4e5fe7a 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -762,6 +762,10 @@ describe("session prompt queue", () => { fn: async () => scoped(tmp.path, async (prompt) => { const session = await sessions.create({ title: "Queued cancel regression" }) + const closed = Promise.withResolvers() + const off = Bus.subscribe(KiloSession.Event.TurnClose, (event) => { + if (event.properties.sessionID === session.id) closed.resolve(event.properties.reason) + }) const first = Effect.runPromise( prompt.prompt({ sessionID: session.id, @@ -804,6 +808,7 @@ describe("session prompt queue", () => { // not leak as an unhandled rejection, but still require rejects to be // interrupt-shaped (not an unrelated provider/session failure). const settled = await Promise.allSettled([first, second, third]) + expect(await closed.promise.finally(off)).toBe("interrupted") for (const r of settled) { if (r.status === "rejected") expect(String(r.reason)).toMatch(/interrupt/i) }