Merge pull request #4016 from code-yeongyu/fix-bg-noti-coalesce
fix(background-agent): coalesce rapid-fire idle parent notifications
This commit is contained in:
@@ -10,8 +10,8 @@
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.7.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opencode-ai/plugin": "1.4.0",
|
||||
"@opencode-ai/sdk": "1.4.0",
|
||||
"@opencode-ai/plugin": "^1.4.0",
|
||||
"@opencode-ai/sdk": "^1.4.0",
|
||||
"commander": "^14.0.3",
|
||||
"detect-libc": "^2.1.2",
|
||||
"diff": "^8.0.4",
|
||||
@@ -31,17 +31,17 @@
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "4.1.1",
|
||||
"oh-my-opencode-darwin-x64": "4.1.1",
|
||||
"oh-my-opencode-darwin-x64-baseline": "4.1.1",
|
||||
"oh-my-opencode-linux-arm64": "4.1.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "4.1.1",
|
||||
"oh-my-opencode-linux-x64": "4.1.1",
|
||||
"oh-my-opencode-linux-x64-baseline": "4.1.1",
|
||||
"oh-my-opencode-linux-x64-musl": "4.1.1",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "4.1.1",
|
||||
"oh-my-opencode-windows-x64": "4.1.1",
|
||||
"oh-my-opencode-windows-x64-baseline": "4.1.1",
|
||||
"oh-my-opencode-darwin-arm64": "4.1.2",
|
||||
"oh-my-opencode-darwin-x64": "4.1.2",
|
||||
"oh-my-opencode-darwin-x64-baseline": "4.1.2",
|
||||
"oh-my-opencode-linux-arm64": "4.1.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "4.1.2",
|
||||
"oh-my-opencode-linux-x64": "4.1.2",
|
||||
"oh-my-opencode-linux-x64-baseline": "4.1.2",
|
||||
"oh-my-opencode-linux-x64-musl": "4.1.2",
|
||||
"oh-my-opencode-linux-x64-musl-baseline": "4.1.2",
|
||||
"oh-my-opencode-windows-x64": "4.1.2",
|
||||
"oh-my-opencode-windows-x64-baseline": "4.1.2",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0",
|
||||
@@ -267,27 +267,27 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.1.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-nciXDvGDWRFvz3OsZo+6IVUp2GwtOOUxZdIlsZklLI+7KC9MMm5FAYbFq2rokk1hkggLCSRROeEd7A4Jm/cYSw=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@4.1.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zX0txRnCdBhDxvlMEcxfIhpEEVEJ/Jgi83G7Mbs7OhzGbr3MkVrdviy164yirO2uo2LVww0cuHvgKP0Mz+YijQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.1.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vk8w7NveZp1JLN0zBe2mcYFaBMPPZ9BvehqSJjYwGnhxzfrnAFopksan0v7N2yr1y0ccOaF31F2XNAglv4rXBA=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@4.1.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Nh6VccQJ3kRlgLqefBXA2eLlS86qBlAGASeg0Tf+1suvSk3NgppfaYJFTctAStD5FuDdrq0HNdFA6e3TAEFsrA=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.1.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0H5npMFOwrmfI6q1Sw+tlucp4VYlT5wAx6PgDrGV1WrF/g1c/f/lkTcXfJApEhmqmSv9uTX0mWy5JRb1FQZ+Nw=="],
|
||||
"oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@4.1.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-IwJlyPyxYpy1KOh8nJWkEZOOh312gYhzP+VtDyGBNXaPI+7sCdP0upZZXvEmekgytNpfOxnl/I9UAvaZnVX3/A=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.1.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-b8kQbbuEYGOZbhtetS9O8xAHbmIZfLwBQVw7lxgloIPU1qietfBxMrRY2ebF3j/UNvK3tE817Xwv01KW0X71Kw=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@4.1.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-EgBWpLmgVlq5R9X69QJ5NTrVwVDckj/OTN90hQ/6+msFh3PCX3wU/PyebNivUka5XP0oOhZ1UeXI0qgMIwPbsw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.1.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ZD/4FCEca82NshK0nNLJrsSYrx55RWOn0nhu3ZKKVyj4hMKWroAqBRvCWde3HxM/e5xUlDHsMJsj7MwvyLpcLg=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@4.1.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-eKnCi2AKoe6+pvvIqyxkAVYfnGeyuUD7+JKfSkvBHMe/ZbNhB26IeqvYKVRu2/U0zHECUUdHSJcruHi5fCefTA=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.1.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-0tgDMdodXuuOomxPvcRFABLX2xte2J4Q2LL1SwTFPw2/JaRa1GYcIX0GzX3PDWfH19jrfO+0RVX/oeXUWbG79w=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@4.1.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Mf3QH8amxwadqoQDcwQx0Nfg92hmCs0pPGskC1MdlS0WS05UMKol4grS2iTCxfoh2BSOZXnnFty4Q4NhGENjUQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.1.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Qb7pnsMU9tZYl/YKYnYOArRNhBDfX6PdkiLuL9onpMGWqxTCV1OsdIIG+LjSU0xJolKpDYMbJesew9XqUshG5w=="],
|
||||
"oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@4.1.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pgzt8kb/+puDp7tXG7GKyyiI2FCYznWdjCbqQ/tzV+ITyWtnim0L8eHFcmDLjUTMVNlCF1KA2b9372pV+cE/YQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.1.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-2lbTzaBnlTeG5Y/pBzZT7KF82PXdxYcrwM0H03pboNZOZdDFf7KG7XXzeVV+9GDfDd7T3VfeRED0MHqcoTXipA=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@4.1.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UtmHVqvxlloKFlA6/TdmJDptZJErD6cn4KZlNN7CEoZsGoE7qdxqCmIsfR8gFjvNbdUDA4+90Wju80ix3mOaig=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.1.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-QWmDD93TrPTD6DHnYmqeZWSjHel8oU3BxvN7XLk/elSqH/GUgC+ml/w6H02V7/59MU9fc557kgGzz6r/5g66IA=="],
|
||||
"oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@4.1.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5QCIuVRRlhR+IR8ui+TVLPE9jLOIU3lLYGvH6RaLhENjSwabDdgB4zusDsHsmlp1MqTSX2Mn4mUsDy8OQwHg6A=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.1.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-zu8Qsb9k9u034tnrf5QABS/u9HdLPc15/n4BrKg4k06DLzqI2pVhK1olvATQsz9W8QdOO+eZ0NY1/Acp5V3Sog=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@4.1.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lVLKB7v5h/hse6mIAXwNqhIxct71PXX0Z/pCCD8q9qll1x4wJxsLIUBNa+CtY46IS0JDYrx+Ik+bnJui0IprxQ=="],
|
||||
|
||||
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.1.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dBeCsO8kLC8cUSfBcMvprKoxLaTGKHuSg+otDdgy53nSN+3pHRxszM9iIQ0YplZsWmTxgpRlyAoL6hMZzZfdsw=="],
|
||||
"oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@4.1.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-mpiREgs2fFTQJxNGDBh99FBi4JTODbPO5PYR4fqM3vCEYS/BRbn7n1w79Ddsh+GKcDG+VPl6Dzi5cwescdyEwA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
@@ -278,6 +278,10 @@ async function flushBackgroundNotifications(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function waitForCoalescedFlush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 400))
|
||||
}
|
||||
|
||||
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
|
||||
_resetTaskToastManagerForTesting()
|
||||
const toastManager = initTaskToastManager(cast<PluginInput["client"]>({
|
||||
@@ -1303,6 +1307,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
//#when
|
||||
await (cast<{ notifyParentSession: (value: BackgroundTask) => Promise<void> }>(manager))
|
||||
.notifyParentSession(task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
expect(capturedBody?.agent).toBe("sisyphus")
|
||||
@@ -1459,6 +1464,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
//#when
|
||||
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
|
||||
.notifyParentSession(task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
expect(promptCalled).toBe(true)
|
||||
@@ -1501,6 +1507,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
//#when
|
||||
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
|
||||
.notifyParentSession(task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
expect(promptCalled).toBe(true)
|
||||
@@ -1541,6 +1548,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
//#when
|
||||
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
|
||||
.notifyParentSession(task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
|
||||
@@ -1652,6 +1660,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
//#when
|
||||
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
|
||||
.notifyParentSession(task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
@@ -1693,6 +1702,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
//#when
|
||||
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
|
||||
.notifyParentSession(task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
@@ -2117,7 +2127,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
// then
|
||||
expect(rejectedCount).toBe(0)
|
||||
expect(promptBodies.length).toBe(2)
|
||||
expect(promptBodies.length).toBe(1)
|
||||
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -5410,6 +5420,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
|
||||
//#when
|
||||
pruneStaleTasksAndNotificationsForTest(manager)
|
||||
await flushBackgroundNotifications()
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
//#then
|
||||
const retainedTask = getTaskMap(manager).get(staleTask.id)
|
||||
|
||||
@@ -111,6 +111,7 @@ type PendingParentWake = {
|
||||
}
|
||||
|
||||
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
|
||||
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
|
||||
|
||||
interface MessagePartInfo {
|
||||
id?: string
|
||||
@@ -2247,32 +2248,19 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
shouldReply,
|
||||
})
|
||||
} else {
|
||||
try {
|
||||
await promptAsyncInDirectory(this.client, {
|
||||
path: { id: task.parentSessionId },
|
||||
body: {
|
||||
noReply: !shouldReply,
|
||||
...parentPromptContext,
|
||||
parts: [createInternalAgentTextPart(notification)],
|
||||
},
|
||||
}, this.directory)
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
isTaskFailure,
|
||||
noReply: !shouldReply,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionId,
|
||||
})
|
||||
this.queuePendingNotification(task.parentSessionId, notification)
|
||||
} else {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
}
|
||||
this.queuePendingParentWake(
|
||||
task.parentSessionId,
|
||||
notification,
|
||||
parentPromptContext,
|
||||
shouldReply,
|
||||
PENDING_PARENT_WAKE_DEBOUNCE_MS,
|
||||
)
|
||||
log("[background-agent] Queued notification for short-debounce flush to idle parent:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
isTaskFailure,
|
||||
shouldReply,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
|
||||
@@ -2295,6 +2283,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
notification: string,
|
||||
promptContext: ParentWakePromptContext,
|
||||
shouldReply: boolean,
|
||||
delayMs?: number,
|
||||
): void {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (pendingWake) {
|
||||
@@ -2308,12 +2297,11 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
shouldReply,
|
||||
})
|
||||
}
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
this.schedulePendingParentWakeFlush(sessionID, delayMs)
|
||||
}
|
||||
|
||||
private async flushPendingParentWake(sessionID: string): Promise<void> {
|
||||
const pendingWake = this.pendingParentWakes.get(sessionID)
|
||||
if (!pendingWake) {
|
||||
if (!this.pendingParentWakes.has(sessionID)) {
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -2323,24 +2311,28 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingParentWakes.delete(sessionID)
|
||||
this.clearPendingParentWakeTimer(sessionID)
|
||||
await settleAfterSessionIdle()
|
||||
|
||||
if (await this.isSessionActive(sessionID)) {
|
||||
this.pendingParentWakes.set(sessionID, pendingWake)
|
||||
this.schedulePendingParentWakeFlush(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const notificationContent = pendingWake.notifications.join("\n\n")
|
||||
const latestWake = this.pendingParentWakes.get(sessionID)
|
||||
if (!latestWake) {
|
||||
return
|
||||
}
|
||||
this.pendingParentWakes.delete(sessionID)
|
||||
|
||||
const notificationContent = latestWake.notifications.join("\n\n")
|
||||
|
||||
try {
|
||||
await promptAsyncInDirectory(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
noReply: !pendingWake.shouldReply,
|
||||
...pendingWake.promptContext,
|
||||
noReply: !latestWake.shouldReply,
|
||||
...latestWake.promptContext,
|
||||
parts: [createInternalAgentTextPart(notificationContent)],
|
||||
},
|
||||
}, this.directory)
|
||||
@@ -2351,7 +2343,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePendingParentWakeFlush(sessionID: string): void {
|
||||
private schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void {
|
||||
if (this.pendingParentWakeTimers.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
@@ -2361,7 +2353,7 @@ The task was re-queued on a fallback model after a retryable failure.
|
||||
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
|
||||
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
|
||||
})
|
||||
}, PENDING_PARENT_WAKE_RETRY_MS)
|
||||
}, delayMs ?? PENDING_PARENT_WAKE_RETRY_MS)
|
||||
|
||||
this.pendingParentWakeTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ function waitForDeferredWakeRetry(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 1_180))
|
||||
}
|
||||
|
||||
function waitForCoalescedFlush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 400))
|
||||
}
|
||||
|
||||
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
|
||||
const timer = getCompletionTimers(manager).get(taskID)
|
||||
expect(timer).toBeDefined()
|
||||
@@ -225,11 +229,10 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
})
|
||||
|
||||
describe("#given background tasks for same parent", () => {
|
||||
test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => {
|
||||
test("#when two completions arrive back-to-back while parent is idle #then one batched notification is sent with both tasks", async () => {
|
||||
// given
|
||||
const { manager, promptAsyncCalls } = createManager(true)
|
||||
managerUnderTest = manager
|
||||
fakeTimers = installFakeTimers()
|
||||
const taskA = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
|
||||
const taskB = createTask({ id: "task-b", parentSessionId: "parent-1", description: "task B", status: "running" })
|
||||
getTasks(manager).set(taskA.id, taskA)
|
||||
@@ -242,25 +245,60 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
|
||||
// when
|
||||
await notifyParentSessionForTest(manager, taskB)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(2)
|
||||
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
|
||||
expect(getCompletionTimers(manager).size).toBe(2)
|
||||
const allCompleteCall = promptAsyncCalls[1]
|
||||
expect(allCompleteCall).toBeDefined()
|
||||
if (!allCompleteCall) {
|
||||
throw new Error("Missing all-complete notification call")
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
const batchedCall = promptAsyncCalls[0]
|
||||
if (!batchedCall) {
|
||||
throw new Error("Missing batched notification call")
|
||||
}
|
||||
expect(batchedCall.body.noReply).toBe(false)
|
||||
const batchedPayload = JSON.stringify(batchedCall.body.parts)
|
||||
expect(batchedPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(batchedPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
|
||||
expect(batchedPayload).toContain(taskA.id)
|
||||
expect(batchedPayload).toContain(taskB.id)
|
||||
expect(batchedPayload).toContain(taskA.description)
|
||||
expect(batchedPayload).toContain(taskB.description)
|
||||
})
|
||||
|
||||
expect(allCompleteCall.body.noReply).toBe(false)
|
||||
const allCompletePayload = JSON.stringify(allCompleteCall.body.parts)
|
||||
expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
expect(allCompletePayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
|
||||
expect(allCompletePayload).toContain(taskA.id)
|
||||
expect(allCompletePayload).toContain(taskB.id)
|
||||
expect(allCompletePayload).toContain(taskA.description)
|
||||
expect(allCompletePayload).toContain(taskB.description)
|
||||
test("#when many completions arrive in rapid succession while parent is idle #then a single coalesced notification is sent", async () => {
|
||||
// given
|
||||
const { manager, promptAsyncCalls } = createManager(true)
|
||||
managerUnderTest = manager
|
||||
const taskIds = ["task-1", "task-2", "task-3", "task-4", "task-5"]
|
||||
const tasks = taskIds.map((id, index) => createTask({
|
||||
id,
|
||||
parentSessionId: "parent-1",
|
||||
description: `description ${id}`,
|
||||
status: "completed",
|
||||
completedAt: new Date(`2026-03-11T00:01:0${index}.000Z`),
|
||||
}))
|
||||
for (const task of tasks) {
|
||||
getTasks(manager).set(task.id, task)
|
||||
}
|
||||
getPendingByParent(manager).set("parent-1", new Set(taskIds))
|
||||
|
||||
// when
|
||||
for (const task of tasks) {
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
}
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
const batchedCall = promptAsyncCalls[0]
|
||||
if (!batchedCall) {
|
||||
throw new Error("Missing batched notification call")
|
||||
}
|
||||
expect(batchedCall.body.noReply).toBe(false)
|
||||
const batchedPayload = JSON.stringify(batchedCall.body.parts)
|
||||
expect(batchedPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
|
||||
for (const task of tasks) {
|
||||
expect(batchedPayload).toContain(task.id)
|
||||
expect(batchedPayload).toContain(task.description)
|
||||
}
|
||||
})
|
||||
|
||||
test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => {
|
||||
@@ -362,6 +400,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
|
||||
|
||||
// when
|
||||
await notifyParentSessionForTest(manager, task)
|
||||
await waitForCoalescedFlush()
|
||||
|
||||
// then
|
||||
expect(promptAsyncCalls).toHaveLength(1)
|
||||
|
||||
Reference in New Issue
Block a user