From 7cce0c417460f8289cc916750e15248d53fd5f8a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 07:00:49 +0000 Subject: [PATCH] fix(server): hold background liveness until a waking task resumes A Claude task notification after the parent turn settles cleared background liveness before the follow-up turn started. The thread read as ready in that gap, so the shared completion trigger played the sound, toast, and desktop notification. Mark that completion as resuming the provider and keep liveness working until the follow-up turn starts. Fixes #13625 Co-authored-by: maco --- .../Layers/ProviderRuntimeIngestion.test.ts | 140 ++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 28 ++++ .../ThreadBackgroundLiveness.test.ts | 97 ++++++++++++ .../orchestration/ThreadBackgroundLiveness.ts | 68 ++++++++- .../src/provider/Layers/ClaudeAdapter.test.ts | 65 ++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 17 +++ apps/web/src/components/Sidebar.logic.test.ts | 13 ++ .../ThreadNotificationCoordinator.test.tsx | 70 ++++++++- packages/contracts/src/providerRuntime.ts | 6 + 9 files changed, 499 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9b5b56309749..672bad706611 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -4680,6 +4680,146 @@ describe("ProviderRuntimeIngestion", () => { expect(activity?.payload).toMatchObject({ requestId: "message-compact" }); }); + it("keeps background liveness through a provider resume until the follow-up turn starts", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + const provider = ProviderDriverKind.make("claudeAgent"); + + await harness.emitAndDrain([ + { + type: "task.started", + eventId: asEventId("evt-resume-task-started"), + provider, + createdAt: now, + threadId, + payload: { + taskId: "subagent-1", + taskType: "local_agent", + description: "Review the diff", + }, + }, + ]); + expect((await harness.readThreadShell()).backgroundLiveness).toBe("working"); + + await harness.emitAndDrain([ + { + type: "turn.completed", + eventId: asEventId("evt-resume-turn-completed"), + provider, + createdAt: now, + threadId, + turnId: asTurnId("turn-parent"), + payload: { state: "completed" }, + }, + ]); + expect((await harness.readThreadShell()).session?.status).toBe("ready"); + expect((await harness.readThreadShell()).backgroundLiveness).toBe("working"); + + await harness.emitAndDrain([ + { + type: "task.completed", + eventId: asEventId("evt-resume-task-completed"), + provider, + createdAt: now, + threadId, + payload: { + taskId: "subagent-1", + status: "completed", + resumesProvider: true, + }, + }, + ]); + const held = await harness.readThreadShell(); + expect(held.session?.status).toBe("ready"); + expect(held.backgroundLiveness).toBe("working"); + + await harness.emitAndDrain([ + { + type: "turn.started", + eventId: asEventId("evt-resume-follow-up"), + provider, + createdAt: "2026-01-01T00:00:02.000Z", + threadId, + turnId: asTurnId("turn-follow-up"), + }, + ]); + const resumed = await harness.readThreadShell(); + expect(resumed.session?.status).toBe("running"); + expect(resumed.backgroundLiveness).toBeNull(); + }); + + it("clears background liveness when a completion will not resume the provider", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + const provider = ProviderDriverKind.make("claudeAgent"); + + await harness.emitAndDrain([ + { + type: "task.started", + eventId: asEventId("evt-settle-task-started"), + provider, + createdAt: now, + threadId, + payload: { + taskId: "monitor-1", + taskType: "local_bash", + description: "Watch the checks", + }, + }, + { + type: "task.completed", + eventId: asEventId("evt-settle-task-completed"), + provider, + createdAt: now, + threadId, + payload: { + taskId: "monitor-1", + status: "completed", + }, + }, + ]); + expect((await harness.readThreadShell()).backgroundLiveness).toBeNull(); + }); + + it("drops a provider-resume hold when the session errors", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + const provider = ProviderDriverKind.make("claudeAgent"); + + await harness.emitAndDrain([ + { + type: "task.completed", + eventId: asEventId("evt-error-task-completed"), + provider, + createdAt: now, + threadId, + payload: { + taskId: "subagent-1", + status: "completed", + resumesProvider: true, + }, + }, + ]); + expect((await harness.readThreadShell()).backgroundLiveness).toBe("working"); + + await harness.emitAndDrain([ + { + type: "session.state.changed", + eventId: asEventId("evt-resume-session-error"), + provider, + createdAt: now, + threadId, + payload: { state: "error", reason: "provider crashed" }, + }, + ]); + const failed = await harness.readThreadShell(); + expect(failed.session?.status).toBe("error"); + expect(failed.backgroundLiveness).toBeNull(); + }); + it("projects Codex task lifecycle chunks into thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 072ac5110b9d..2f376a4d21f2 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1778,6 +1778,12 @@ const make = Effect.gen(function* () { }, ); + /** + * Fold one provider runtime event into the thread: session lifecycle, + * activities, and in-memory background liveness. A waking task completion + * keeps that liveness working until the follow-up turn starts, so the + * shell does not read as ready in the gap. + */ const processRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { if ( @@ -2504,6 +2510,7 @@ const make = Effect.gen(function* () { taskType?: string; status?: string; agentId?: string; + resumesProvider?: boolean; }; threadBackgroundLiveness.recordTaskLiveness({ threadId: thread.id, @@ -2519,9 +2526,30 @@ const make = Effect.gen(function* () { : event.type === "task.updated" ? "updated" : "completed", + awaitsProviderResume: + event.type === "task.completed" && payload.resumesProvider === true, }); break; } + case "turn.started": + // The follow-up turn is running. Session status keeps the thread + // working until that turn settles, so the resume hold can drop. + if (shouldApplyThreadLifecycle) { + threadBackgroundLiveness.releaseProviderResume(thread.id); + } + break; + case "turn.aborted": + if (shouldApplyThreadLifecycle) { + threadBackgroundLiveness.releaseProviderResume(thread.id); + } + break; + case "session.state.changed": + // Ready is the gap itself. Release only when the session can no + // longer resume, so a failure is not pinned on Working. + if (event.payload.state === "error" || event.payload.state === "stopped") { + threadBackgroundLiveness.releaseProviderResume(thread.id); + } + break; case "session.exited": threadBackgroundLiveness.clearThreadLiveness(thread.id); break; diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts index b4c528480fc7..9a5ab5a4c7e7 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts @@ -226,4 +226,101 @@ describe("ThreadBackgroundLiveness", () => { a.clearThreadLiveness("t"); expect(a.getThreadBackgroundLiveness("t")).toBeNull(); }); + + it("holds working after a waking completion until the provider resumes", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "thread-resume"; + liveness.recordTaskLiveness({ + threadId, + taskId: "subagent", + taskType: "local_agent", + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId, + taskId: "monitor", + taskType: "local_bash", + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId, + taskId: "subagent", + taskType: "local_agent", + status: "completed", + kind: "completed", + awaitsProviderResume: true, + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); + liveness.recordTaskLiveness({ + threadId, + taskId: "monitor", + taskType: "local_bash", + status: "completed", + kind: "completed", + awaitsProviderResume: true, + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + liveness.releaseProviderResume(threadId); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + + it("does not hold a completion that will not resume the provider", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "thread-settle"; + liveness.recordTaskLiveness({ + threadId, + taskId: "monitor", + taskType: "local_bash", + status: undefined, + kind: "started", + }); + liveness.recordTaskLiveness({ + threadId, + taskId: "monitor", + taskType: "local_bash", + status: "completed", + kind: "completed", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull(); + }); + + it("drops a resume hold when the thread's background work is cleared", () => { + const liveness = ThreadBackgroundLiveness.make(); + liveness.recordTaskLiveness({ + threadId: "thread", + taskId: "subagent", + taskType: "local_agent", + status: "completed", + kind: "completed", + awaitsProviderResume: true, + }); + expect(liveness.getThreadBackgroundLiveness("thread")).toBe("working"); + liveness.clearThreadLiveness("thread"); + expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); + }); + + it("releases only the resume hold and leaves a task that started during the handoff", () => { + const liveness = ThreadBackgroundLiveness.make(); + const threadId = "thread-handoff"; + liveness.recordTaskLiveness({ + threadId, + taskId: "subagent", + taskType: "local_agent", + status: "completed", + kind: "completed", + awaitsProviderResume: true, + }); + liveness.recordTaskLiveness({ + threadId, + taskId: "monitor", + taskType: "local_bash", + status: undefined, + kind: "started", + }); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working"); + liveness.releaseProviderResume(threadId); + expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring"); + }); }); diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts index 2781e4981f7c..187006600ae7 100644 --- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts +++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts @@ -13,6 +13,12 @@ * shells) when they are the ONLY live work; any agent work presents as * "working". * + * A waking completion keeps the thread "working" after the last live task + * drops, until `releaseProviderResume`. The shell then does not read as + * ready between that result and the follow-up turn, so the shared + * completion alert stays quiet. A completion that will not resume the + * provider clears normally. + * * @module ThreadBackgroundLivenessService */ import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts"; @@ -59,8 +65,22 @@ export class ThreadBackgroundLivenessService extends Context.Service< readonly status: string | undefined; readonly kind: "started" | "progress" | "updated" | "completed"; readonly agentId?: string | undefined; + /** + * The provider will resume after this transition (a Claude task + * notification once the parent turn has settled). When the transition + * leaves nothing else live, the thread stays "working" until + * `releaseProviderResume`. + */ + readonly awaitsProviderResume?: boolean | undefined; }) => void; + /** + * The follow-up turn started, or the session can no longer resume + * (error, stopped, interrupted, exit). Drops a waking-completion hold. + * Live tasks are left in place. + */ + readonly releaseProviderResume: (threadId: string) => void; + /** Session death orphans all of a thread's background work. */ readonly clearThreadLiveness: (threadId: string) => void; @@ -72,8 +92,10 @@ export class ThreadBackgroundLivenessService extends Context.Service< } >()("t3/orchestration/ThreadBackgroundLiveness/ThreadBackgroundLivenessService") {} +/** Build the in-memory registry the shell reads for background liveness. */ export function make(): ThreadBackgroundLivenessService["Service"] { const stateByThreadId = new Map(); + const resumeHolds = new Set(); const stateFor = (threadId: string): ThreadLivenessState => { const existing = stateByThreadId.get(threadId); @@ -102,10 +124,30 @@ export function make(): ThreadBackgroundLivenessService["Service"] { }; return { + /** + * Apply one task transition, then arm a resume hold when this + * completion wakes the provider and nothing else is still live. + */ recordTaskLiveness: (input) => { + /** + * Keep the thread working through the handoff after a waking + * completion. Other live tasks already cover the sidebar. + */ + const armProviderResumeIfIdle = () => { + if (input.awaitsProviderResume !== true) { + return; + } + const state = stateByThreadId.get(input.threadId); + const stillLive = state !== undefined && (state.agents.size > 0 || state.monitors.size > 0); + if (!stillLive) { + resumeHolds.add(input.threadId); + } + }; + const taskType = input.taskType; if (taskType !== undefined && INERT_TASK_TYPES.has(taskType)) { drop(input.threadId, input.taskId); + armProviderResumeIfIdle(); return; } // A subagent's internal non-agent work (its own shells/monitors) is @@ -116,6 +158,7 @@ export function make(): ThreadBackgroundLivenessService["Service"] { (taskType === undefined || MONITOR_TASK_TYPES.has(taskType)) ) { drop(input.threadId, input.taskId); + armProviderResumeIfIdle(); return; } @@ -127,6 +170,7 @@ export function make(): ThreadBackgroundLivenessService["Service"] { (input.status !== undefined && TERMINAL_STATUSES.has(input.status)); if (terminal) { drop(input.threadId, input.taskId); + armProviderResumeIfIdle(); return; } @@ -138,6 +182,7 @@ export function make(): ThreadBackgroundLivenessService["Service"] { existing !== undefined && (existing.agents.has(input.taskId) || existing.monitors.has(input.taskId)); if (!stillLive) { + armProviderResumeIfIdle(); return; } } @@ -147,21 +192,36 @@ export function make(): ThreadBackgroundLivenessService["Service"] { const bucket = taskType !== undefined && MONITOR_TASK_TYPES.has(taskType) ? state.monitors : state.agents; bucket.add(input.taskId); + armProviderResumeIfIdle(); + }, + + /** Drop the waking-completion hold. Live tasks stay registered. */ + releaseProviderResume: (threadId) => { + resumeHolds.delete(threadId); }, + /** Drop live tasks and any resume hold for a dead session. */ clearThreadLiveness: (threadId) => { stateByThreadId.delete(threadId); + resumeHolds.delete(threadId); }, + /** + * Live agents win, then a resume hold, then lone watch loops. + * The hold covers the gap after the last task finishes and before + * the follow-up turn starts. + */ getThreadBackgroundLiveness: (threadId) => { const state = stateByThreadId.get(threadId); - if (!state) { - return null; + if (state && state.agents.size > 0) { + return "working"; } - if (state.agents.size > 0) { + // The provider is about to resume. That outranks a quiet monitor + // set and a fully cleared registry — the run has not settled yet. + if (resumeHolds.has(threadId)) { return "working"; } - if (state.monitors.size > 0) { + if (state && state.monitors.size > 0) { return "monitoring"; } return null; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 295926b26644..22821014fd64 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3255,6 +3255,71 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("marks a settled-turn task notification as resuming the provider", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "spawn agents", + attachments: [], + }); + + // streamEvents is one queue, so a single consumer has to see both + // notifications and the turn boundary between them. + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil( + (event) => event.type === "task.completed" && event.payload.resumesProvider === true, + ), + Stream.runCollect, + Effect.forkChild, + ); + const notify = (uuid: string) => { + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-done", + status: "completed", + summary: "done", + uuid, + session_id: "sdk-session", + } as unknown as SDKMessage); + }; + notify("task-during-turn"); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + result: "", + stop_reason: null, + session_id: "sdk-session", + uuid: "result-before-resume", + } as unknown as SDKMessage); + notify("task-after-turn"); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const taskEvents = events.filter((event) => event.type === "task.completed"); + assert.equal(taskEvents.length, 2); + const turnCompletedAt = events.findIndex((event) => event.type === "turn.completed"); + const firstTask = taskEvents[0]; + const secondTask = taskEvents[1]; + assert.equal(firstTask !== undefined && events.indexOf(firstTask) < turnCompletedAt, true); + assert.equal(secondTask !== undefined && events.indexOf(secondTask) > turnCompletedAt, true); + if (firstTask?.type === "task.completed" && secondTask?.type === "task.completed") { + assert.equal(firstTask.payload.resumesProvider, undefined); + assert.equal(secondTask.payload.resumesProvider, true); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps the session available when process close fails", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6e30f5241780..ae78f4555aab 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -2064,6 +2064,16 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { return undefined; } +/** + * True when a task notification arrives with no turn open. The CLI delivers + * that result back to the model, and the next assistant message starts the + * follow-up. Callers mark the completion so sidebar liveness stays working + * until that turn starts. + */ +function claudeTaskNotificationResumesProvider(context: ClaudeSessionContext): boolean { + return context.turnState === undefined; +} + export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( claudeSettings: ClaudeSettings, options?: ClaudeAdapterLiveOptions, @@ -3567,6 +3577,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } }); + /** + * Map one Claude SDK system message onto provider runtime events. + * Task notifications that arrive with no turn open are marked as resuming + * the provider so the waiting turn is not announced done. + */ const handleSystemMessage = Effect.fn("handleSystemMessage")(function* ( context: ClaudeSessionContext, message: SDKMessage, @@ -3835,6 +3850,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } case "task_notification": { context.liveTaskIds.delete(message.task_id); + const resumesProvider = claudeTaskNotificationResumesProvider(context); yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -3854,6 +3870,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(message.usage ? { usage: message.usage } : {}), ...(typedUsage ? { typedUsage } : {}), ...(message.output_file ? { outputFile: message.output_file } : {}), + ...(resumesProvider ? { resumesProvider: true } : {}), ...taskLinkageFor(context.taskAgents, message.task_id), }, }); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fede3c183448..066a4850366d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -843,6 +843,19 @@ describe("resolveSidebarThreadStatus", () => { it("defaults to ready with no session", () => { expect(resolveSidebarThreadStatus({ ...idle, session: null })).toBe("ready"); }); + + it("keeps a ready session working while background liveness is held", () => { + const ready = { ...session, status: "ready" as const }; + expect( + resolveSidebarThreadStatus({ ...idle, session: ready, backgroundLiveness: "working" }), + ).toBe("working"); + expect( + resolveSidebarThreadStatus({ ...idle, session: ready, backgroundLiveness: "monitoring" }), + ).toBe("monitoring"); + expect(resolveSidebarThreadStatus({ ...idle, session: ready, backgroundLiveness: null })).toBe( + "ready", + ); + }); }); describe("searchSidebarThreads", () => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 860b6389dc12..7d33882ceeb5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -17,6 +17,8 @@ const state = vi.hoisted(() => ({ approval: false, sessionError: false, turnError: false, + backgroundLiveness: null as "working" | "monitoring" | null, + sessionStatus: null as "running" | "starting" | "ready" | null, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => "toast-1", @@ -40,7 +42,12 @@ vi.mock("@effect/atom-react", () => ({ archivedAt: state.archivedAt, hasPendingUserInput: state.input, hasPendingApprovals: state.approval, - session: state.sessionError ? { status: "error" } : null, + backgroundLiveness: state.backgroundLiveness, + session: state.sessionError + ? { status: "error" } + : state.sessionStatus + ? { status: state.sessionStatus } + : null, latestTurn: { turnId: "turn-1", state: state.turnError ? "error" : state.completedAt ? "completed" : "running", @@ -109,6 +116,8 @@ beforeEach(() => { approval: false, sessionError: false, turnError: false, + backgroundLiveness: null, + sessionStatus: null, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", new EventTarget()); @@ -239,6 +248,65 @@ describe("thread notifications", () => { expect(state.notification).not.toHaveBeenCalled(); }); + it("alerts a failure immediately while background work is live", async () => { + state.mode = "notifications-and-sound"; + state.backgroundLiveness = "working"; + await render(); + state.sessionError = true; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title: "Thread failed" })); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); + }); + + it("alerts an approval immediately while background work is live", async () => { + state.mode = "notifications-and-sound"; + state.backgroundLiveness = "working"; + await render(); + state.approval = true; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "Approval needed" }), + ); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); + }); + + it("alerts once when background work ends and the agent does not resume", async () => { + state.mode = "notifications-and-sound"; + state.backgroundLiveness = "monitoring"; + state.completedAt = "2026-09-13T10:00:00.000Z"; + await render(); + state.backgroundLiveness = null; + await render(); + expect(state.sound).toHaveBeenCalledTimes(1); + expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "Thread completed" }), + ); + }); + + it("alerts the follow-up once when background work hands off to a running turn", async () => { + state.mode = "notifications-and-sound"; + state.backgroundLiveness = "working"; + state.completedAt = "2026-09-13T10:00:00.000Z"; + await render(); + state.backgroundLiveness = null; + state.sessionStatus = "running"; + state.completedAt = null; + await render(); + expect(state.sound).not.toHaveBeenCalled(); + expect(state.add).not.toHaveBeenCalled(); + + state.sessionStatus = null; + state.completedAt = "2026-09-13T10:05:00.000Z"; + await render(); + expect(state.sound).toHaveBeenCalledTimes(1); + expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); + expect(state.add).toHaveBeenCalledTimes(1); + }); + it("keeps system alerts when the app is in the background", async () => { state.mode = "notifications"; state.focused = false; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 309b61935485..1f291485d6cf 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -675,6 +675,12 @@ const TaskCompletedPayload = Schema.Struct({ summary: Schema.optional(TrimmedNonEmptyStringSchema), usage: Schema.optional(Schema.Unknown), typedUsage: Schema.optional(RuntimeTaskUsage), + /** + * Set when this completion is delivered back to the provider and the + * agent will resume (a Claude task notification after the parent turn + * has already settled). Absent on ordinary terminal rows. + */ + resumesProvider: Schema.optional(Schema.Boolean), ...taskAgentLinkageFields, }); export type TaskCompletedPayload = typeof TaskCompletedPayload.Type;