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.
This commit is contained in:
YeonGyu-Kim
2026-02-05 11:31:54 +09:00
parent 8842a9139f
commit 48cb2033e2
2 changed files with 121 additions and 1 deletions

View File

@@ -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<void> })
.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<void> })
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(true)
manager.shutdown()
})
})
function buildNotificationPromptBody(
task: BackgroundTask,
currentMessage: CurrentMessage | null

View File

@@ -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