fix(background-agent): queue notifications for idle parent sessions
When a background task completes and the parent session is waiting for user input, promptAsync() fails with an aborted error. Previously the notification was silently dropped — lost forever. Fix: queue the notification text in-memory on the BackgroundManager when promptAsync fails with an aborted/idle error. On the user's next message to that session, the queued notifications are injected into the chat context before the agent sees the message. - BackgroundManager: add pendingNotifications map + queuePendingNotification() and injectPendingNotificationsIntoChatMessage() methods - background-notification hook: add chat.message handler that calls injection - chat-message.ts: wire backgroundNotificationHook.chat.message into the message processing chain - Add tests covering queue-on-abort and next-message delivery
This commit is contained in:
@@ -191,6 +191,10 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
|
||||
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
|
||||
}
|
||||
|
||||
function getPendingNotifications(manager: BackgroundManager): Map<string, string[]> {
|
||||
return (manager as unknown as { pendingNotifications: Map<string, string[]> }).pendingNotifications
|
||||
}
|
||||
|
||||
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
|
||||
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
|
||||
}
|
||||
@@ -1057,6 +1061,49 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should queue notification when promptAsync aborts while parent is idle", async () => {
|
||||
//#given
|
||||
const promptMock = async () => {
|
||||
const error = new Error("Request aborted while waiting for input")
|
||||
error.name = "MessageAbortedError"
|
||||
throw error
|
||||
}
|
||||
const client = {
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
promptAsync: promptMock,
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-idle-queue",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task idle queue",
|
||||
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
|
||||
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
|
||||
expect(queuedNotifications).toHaveLength(1)
|
||||
expect(queuedNotifications[0]).toContain("<system-reminder>")
|
||||
expect(queuedNotifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
@@ -1105,6 +1152,29 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.injectPendingNotificationsIntoChatMessage", () => {
|
||||
test("should prepend queued notifications to first text part and clear queue", () => {
|
||||
// given
|
||||
const manager = createBackgroundManager()
|
||||
manager.queuePendingNotification("session-parent", "<system-reminder>queued-one</system-reminder>")
|
||||
manager.queuePendingNotification("session-parent", "<system-reminder>queued-two</system-reminder>")
|
||||
const output = {
|
||||
parts: [{ type: "text", text: "User prompt" }],
|
||||
}
|
||||
|
||||
// when
|
||||
manager.injectPendingNotificationsIntoChatMessage(output, "session-parent")
|
||||
|
||||
// then
|
||||
expect(output.parts[0].text).toContain("<system-reminder>queued-one</system-reminder>")
|
||||
expect(output.parts[0].text).toContain("<system-reminder>queued-two</system-reminder>")
|
||||
expect(output.parts[0].text).toContain("User prompt")
|
||||
expect(getPendingNotifications(manager).get("session-parent")).toBeUndefined()
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
function buildNotificationPromptBody(
|
||||
task: BackgroundTask,
|
||||
currentMessage: CurrentMessage | null
|
||||
|
||||
@@ -93,6 +93,7 @@ export class BackgroundManager {
|
||||
|
||||
private tasks: Map<string, BackgroundTask>
|
||||
private notifications: Map<string, BackgroundTask[]>
|
||||
private pendingNotifications: Map<string, string[]>
|
||||
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
@@ -125,6 +126,7 @@ export class BackgroundManager {
|
||||
) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.pendingNotifications = new Map()
|
||||
this.pendingByParent = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
@@ -917,6 +919,32 @@ export class BackgroundManager {
|
||||
this.notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
queuePendingNotification(sessionID: string | undefined, notification: string): void {
|
||||
if (!sessionID) return
|
||||
const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
|
||||
existingNotifications.push(notification)
|
||||
this.pendingNotifications.set(sessionID, existingNotifications)
|
||||
}
|
||||
|
||||
injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
|
||||
const pendingNotifications = this.pendingNotifications.get(sessionID)
|
||||
if (!pendingNotifications || pendingNotifications.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingNotifications.delete(sessionID)
|
||||
const notificationContent = pendingNotifications.join("\n\n")
|
||||
const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
|
||||
|
||||
if (firstTextPartIndex === -1) {
|
||||
output.parts.unshift(createInternalAgentTextPart(notificationContent))
|
||||
return
|
||||
}
|
||||
|
||||
const originalText = output.parts[firstTextPartIndex].text ?? ""
|
||||
output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a session has actual assistant/tool output before marking complete.
|
||||
* Prevents premature completion when session.idle fires before agent responds.
|
||||
@@ -1340,6 +1368,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
this.queuePendingNotification(task.parentSessionID, notification)
|
||||
} else {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
@@ -1568,6 +1597,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
this.concurrencyManager.clear()
|
||||
this.tasks.clear()
|
||||
this.notifications.clear()
|
||||
this.pendingNotifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
this.notificationQueueByParent.clear()
|
||||
this.queuesByKey.clear()
|
||||
|
||||
Reference in New Issue
Block a user