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;