From 8084f9dfd86537bd33ed3c98ac6393735456e11f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 10 Mar 2026 20:44:34 -0400 Subject: [PATCH] fix(session): distinguish idle reasons for completion and abort --- .../opencode/src/server/routes/session.ts | 2 +- packages/opencode/src/session/processor.ts | 5 ++- packages/opencode/src/session/prompt.ts | 33 +++++++++++++++---- packages/opencode/src/session/status.ts | 12 +++++-- packages/opencode/src/tool/task.ts | 2 +- packages/sdk/js/src/gen/types.gen.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 7 files changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeaba04..eff861499ad3 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -376,7 +376,7 @@ export const SessionRoutes = lazy(() => }), ), async (c) => { - SessionPrompt.cancel(c.req.valid("param").sessionID) + SessionPrompt.cancel(c.req.valid("param").sessionID, "aborted") return c.json(true) }, ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 67edc0ecfe35..d739e3e23765 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -381,7 +381,10 @@ export namespace SessionProcessor { sessionID: input.assistantMessage.sessionID, error: input.assistantMessage.error, }) - SessionStatus.set(input.sessionID, { type: "idle" }) + SessionStatus.set(input.sessionID, { + type: "idle", + reason: error.name === "MessageAbortedError" ? "aborted" : "error", + }) } } if (snapshot) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7698b78baba2..795b9cbaa9b7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -254,17 +254,21 @@ export namespace SessionPrompt { return s[sessionID].abort.signal } - export function cancel(sessionID: string) { + export function cancel(sessionID: string, reason: SessionStatus.IdleReason = "aborted") { log.info("cancel", { sessionID }) + const idle = () => { + if (SessionStatus.get(sessionID).type === "idle") return + SessionStatus.set(sessionID, { type: "idle", reason }) + } const s = state() const match = s[sessionID] if (!match) { - SessionStatus.set(sessionID, { type: "idle" }) + idle() return } match.abort.abort() delete s[sessionID] - SessionStatus.set(sessionID, { type: "idle" }) + idle() return } @@ -283,7 +287,8 @@ export namespace SessionPrompt { }) } - using _ = defer(() => cancel(sessionID)) + let reason: SessionStatus.IdleReason = "completed" + using _ = defer(() => cancel(sessionID, reason)) // Structured output state // Note: On session resumption, state is reset but outputFormat is preserved @@ -295,7 +300,10 @@ export namespace SessionPrompt { while (true) { SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) - if (abort.aborted) break + if (abort.aborted) { + reason = "aborted" + break + } let msgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) let lastUser: MessageV2.User | undefined @@ -536,7 +544,10 @@ export namespace SessionPrompt { auto: task.auto, overflow: task.overflow, }) - if (result === "stop") break + if (result === "stop") { + reason = abort.aborted ? "aborted" : "completed" + break + } continue } @@ -698,11 +709,19 @@ export namespace SessionPrompt { retries: 0, }).toObject() await Session.updateMessage(processor.message) + reason = "error" break } } - if (result === "stop") break + if (result === "stop") { + if (processor.message.error?.name === "MessageAbortedError") { + reason = "aborted" + } else if (processor.message.error) { + reason = "error" + } + break + } if (result === "compact") { await SessionCompaction.create({ sessionID, diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1db03b5db0d8..c601dab71d7b 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -4,10 +4,14 @@ import { Instance } from "@/project/instance" import z from "zod" export namespace SessionStatus { + export const IdleReason = z.enum(["completed", "aborted", "error"]) + export type IdleReason = z.infer + export const Info = z .union([ z.object({ type: z.literal("idle"), + reason: IdleReason.optional(), }), z.object({ type: z.literal("retry"), @@ -65,9 +69,11 @@ export namespace SessionStatus { }) if (status.type === "idle") { // deprecated - Bus.publish(Event.Idle, { - sessionID, - }) + if (!status.reason || status.reason === "completed") { + Bus.publish(Event.Idle, { + sessionID, + }) + } delete state()[sessionID] return } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 8c8cf827abaf..836f1dcb0164 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -119,7 +119,7 @@ export const TaskTool = Tool.define("task", async (ctx) => { const messageID = Identifier.ascending("message") function cancel() { - SessionPrompt.cancel(session.id) + SessionPrompt.cancel(session.id, "aborted") } ctx.abort.addEventListener("abort", cancel) using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe985..825b06cb1bb4 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -453,6 +453,7 @@ export type EventPermissionReplied = { export type SessionStatus = | { type: "idle" + reason?: "completed" | "aborted" | "error" } | { type: "retry" diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a47b18db2192..ab39062347b6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -581,6 +581,7 @@ export type EventPermissionReplied = { export type SessionStatus = | { type: "idle" + reason?: "completed" | "aborted" | "error" } | { type: "retry"