From 48cb2033e2c3ea6aa0118df06f315cd8ad11146c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 5 Feb 2026 11:31:54 +0900 Subject: [PATCH] fix(background-agent): gracefully handle aborted parent session in notifyParentSession When the main session is aborted while background tasks are running, notifyParentSession() would attempt to call session.messages() and session.prompt() on the aborted parent session, causing exceptions that could crash the TUI. - Add isAbortedSessionError() helper to detect abort-related errors - Add abort check in session.messages() catch block with early return - Add abort check in session.prompt() catch block with early return - Add test case covering aborted parent session scenario Fixes TUI crash when aborting main session with running background tasks. --- src/features/background-agent/manager.test.ts | 84 +++++++++++++++++++ src/features/background-agent/manager.ts | 38 ++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 1b45ccf4d..cef1ef935 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -875,6 +875,90 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => }) }) +describe("BackgroundManager.notifyParentSession - aborted parent", () => { + test("should skip notification when parent session is aborted", async () => { + //#given + let promptCalled = false + const client = { + session: { + prompt: async () => { + promptCalled = true + return {} + }, + abort: async () => ({}), + messages: async () => { + const error = new Error("User aborted") + error.name = "MessageAbortedError" + throw error + }, + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const task: BackgroundTask = { + id: "task-aborted-parent", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task aborted parent", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getPendingByParent(manager).set("session-parent", new Set([task.id, "task-remaining"])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) + .notifyParentSession(task) + + //#then + expect(promptCalled).toBe(false) + + manager.shutdown() + }) + + test("should swallow aborted error from prompt", async () => { + //#given + let promptCalled = false + const client = { + session: { + prompt: async () => { + promptCalled = true + const error = new Error("User aborted") + error.name = "MessageAbortedError" + throw error + }, + abort: async () => ({}), + messages: async () => ({ data: [] }), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const task: BackgroundTask = { + id: "task-aborted-prompt", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task aborted prompt", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getPendingByParent(manager).set("session-parent", new Set([task.id])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) + .notifyParentSession(task) + + //#then + expect(promptCalled).toBe(true) + + manager.shutdown() + }) +}) + function buildNotificationPromptBody( task: BackgroundTask, currentMessage: CurrentMessage | null diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 768b33383..0938a81b6 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1123,7 +1123,14 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea break } } - } catch { + } catch (error) { + if (this.isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } const messageDir = getMessageDir(task.parentSessionID) const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null agent = currentMessage?.agent ?? task.parentAgent @@ -1154,6 +1161,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea noReply: !allComplete, }) } catch (error) { + if (this.isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } log("[background-agent] Failed to send notification:", error) } @@ -1192,6 +1206,28 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea return `${seconds}s` } + private isAbortedSessionError(error: unknown): boolean { + const message = this.getErrorText(error) + return message.toLowerCase().includes("aborted") + } + + private getErrorText(error: unknown): string { + if (!error) return "" + if (typeof error === "string") return error + if (error instanceof Error) { + return `${error.name}: ${error.message}` + } + if (typeof error === "object" && error !== null) { + if ("message" in error && typeof error.message === "string") { + return error.message + } + if ("name" in error && typeof error.name === "string") { + return error.name + } + } + return "" + } + private hasRunningTasks(): boolean { for (const task of this.tasks.values()) { if (task.status === "running") return true