diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index d5b9357f32a6..3c67ffa892a8 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1,5 +1,6 @@ import { assert, it, vi } from "@effect/vitest"; import { + EnvironmentId, EventId, CommandId, CheckpointId, @@ -25,6 +26,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -4179,4 +4181,120 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { ); }), ); + + it.effect("dates a completion released by held background work at the release", () => + Effect.gen(function* () { + const store = yield* ProjectionStoreV2; + const threadId = ThreadId.make("thread:held-completion"); + const runId = RunId.make("run:held-completion"); + const providerThreadId = ProviderThreadId.make("provider-thread:held-completion"); + const completedAt = DateTime.makeUnsafe("2026-09-28T12:00:00.000Z"); + const releasedAt = DateTime.makeUnsafe("2026-09-28T12:05:00.000Z"); + const providerThread = { + id: providerThreadId, + driver, + providerInstanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" as const }], + createdAt: completedAt, + updatedAt: completedAt, + }; + yield* store.apply({ + id: EventId.make("event:held-completion:thread"), + type: "thread.created", + threadId, + occurredAt: completedAt, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:held-completion"), + title: "Watch the build", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: completedAt, + updatedAt: completedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* store.apply({ + id: EventId.make("event:held-completion:run"), + type: "run.created", + threadId, + runId, + driver, + providerInstanceId, + occurredAt: completedAt, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId, + userMessageId: MessageId.make("message:held-completion"), + rootNodeId: null, + activeAttemptId: null, + status: "completed", + requestedAt: completedAt, + startedAt: completedAt, + completedAt, + checkpointId: null, + contextHandoffId: null, + }, + }); + yield* store.apply({ + id: EventId.make("event:held-completion:monitor-running"), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: completedAt, + payload: providerThread, + }); + const project = { title: "Project" }; + const environmentId = EnvironmentId.make("environment:held-completion"); + const held = yield* store.getThreadShell(threadId); + assert.equal( + held && projectThreadAwarenessV2({ environmentId, project, thread: held })?.phase, + "running", + ); + + // The monitor ends five minutes after the run: the push must not look stale. + yield* store.apply({ + id: EventId.make("event:held-completion:monitor-ended"), + type: "provider-thread.updated", + threadId, + driver, + providerInstanceId, + occurredAt: releasedAt, + payload: { ...providerThread, pendingBackgroundTasks: [], updatedAt: releasedAt }, + }); + const released = yield* store.getThreadShell(threadId); + const state = + released && projectThreadAwarenessV2({ environmentId, project, thread: released }); + assert.equal(state?.phase, "completed"); + assert.equal(state?.updatedAt, DateTime.formatIso(releasedAt)); + }), + ); }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 845ef0d31e93..ddc6efcabe6e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -2,10 +2,17 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { EnvironmentId, + EventId, + MessageId, + NodeId, + type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, + type OrchestrationV2TurnItem, ProjectId, ProviderInstanceId, + RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import { RelayAgentActivityState } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; @@ -13,6 +20,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -27,6 +35,10 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import { + layerMemory as projectionStoreMemoryLayer, + ProjectionStoreV2, +} from "../orchestration-v2/ProjectionStore.ts"; import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; import { ProjectService } from "../project/ProjectService.ts"; import { @@ -137,6 +149,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( readonly failSecretRead?: (name: string) => boolean; /** Starts unlinked with publishing off when false. */ readonly linked?: boolean; + /** Serves shells from this source instead of `currentShell`. */ + readonly readShell?: (threadId: ThreadId) => Effect.Effect; + readonly domainEvents?: Stream.Stream; } = {}, ) { const values = new Map( @@ -172,7 +187,9 @@ const makeTestRelay = Effect.fnUntraced(function* ( const catchUp = { shellSnapshotReads: 0 }; const threads = ThreadManagementService.of({ getThreadShell: (threadId) => - Effect.sync(() => shellReads.push(threadId)).pipe(Effect.andThen(Ref.get(currentShell))), + Effect.sync(() => shellReads.push(threadId)).pipe( + Effect.andThen(options.readShell?.(threadId) ?? Ref.get(currentShell)), + ), getShellSnapshot: () => Effect.sync(() => { catchUp.shellSnapshotReads += 1; @@ -196,7 +213,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getThreadEventSequence: unused, streamStoredEvents: Stream.empty, streamStoredEventsFrom: () => Stream.empty, - streamDomainEvents: Stream.empty, + streamDomainEvents: options.domainEvents ?? Stream.empty, }); const publications: Array<{ readonly url: string; @@ -277,6 +294,9 @@ describe("AgentAwarenessRelay", () => { "run.created", "run.updated", "runtime-request.updated", + // Pending background work changes can release a held completion. + "subagent.updated", + "provider-thread.updated", "thread.metadata-updated", "thread.model-selection-updated", "thread.provider-switched", @@ -665,6 +685,166 @@ describe("AgentAwarenessRelay", () => { assert.equal(publications.length, 1); }), ); + it.effect("publishes a held completion when its background item ends, not on tool output", () => + Effect.gen(function* () { + const store = yield* ProjectionStoreV2; + const events = yield* Queue.unbounded(); + // The relay pulls the next event only after it has handled the previous one. + const pulls = yield* Queue.unbounded(); + const { relay, shellReads, publications } = yield* makeTestRelay({ + readShell: (threadId) => store.getThreadShell(threadId).pipe(Effect.orDie), + domainEvents: Stream.fromEffectRepeat( + Queue.offer(pulls, undefined).pipe(Effect.andThen(Queue.take(events))), + ), + }); + yield* relay.start(); + yield* Queue.take(pulls); + const deliver = Effect.fnUntraced(function* (event: OrchestrationV2DomainEvent) { + yield* store.apply(event); + yield* Queue.offer(events, event); + yield* Queue.take(pulls); + yield* relay.drain; + }); + + const now = yield* DateTime.now; + const runId = RunId.make("run:held-item"); + const run = { + id: runId, + threadId: THREAD_ID, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + providerThreadId: null, + userMessageId: MessageId.make("message:held-item"), + rootNodeId: null, + activeAttemptId: null, + status: "running" as const, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + const item = ( + id: string, + fields: Pick & + ( + | { readonly type: "dynamic_tool"; readonly toolName: string; readonly input: unknown } + | { + readonly type: "assistant_message"; + readonly messageId: MessageId; + readonly text: string; + readonly streaming: boolean; + } + ), + ): OrchestrationV2TurnItem => ({ + id: TurnItemId.make(id), + threadId: THREAD_ID, + runId, + nodeId: NodeId.make("node:held-item"), + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + ...fields, + }); + const itemEvent = (id: string, payload: OrchestrationV2TurnItem) => + ({ + id: EventId.make(id), + type: "turn-item.updated", + threadId: THREAD_ID, + runId, + occurredAt: now, + payload, + }) satisfies OrchestrationV2DomainEvent; + const background = { type: "dynamic_tool" as const, toolName: "watch", input: {} }; + + yield* deliver({ + id: EventId.make("event:held-item:thread"), + type: "thread.created", + threadId: THREAD_ID, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "test-model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: THREAD_ID, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* deliver({ + id: EventId.make("event:held-item:run"), + type: "run.created", + threadId: THREAD_ID, + runId, + occurredAt: now, + payload: run, + }); + yield* deliver( + itemEvent("event:held-item:tool", item("item:tool", { ...background, status: "running" })), + ); + yield* deliver({ + id: EventId.make("event:held-item:run-completed"), + type: "run.updated", + threadId: THREAD_ID, + runId, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }); + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running"], + ); + + // Streaming output never reaches the shell read. + const readsBeforeOutput = shellReads.length; + yield* deliver( + itemEvent( + "event:held-item:reply", + item("item:reply", { + type: "assistant_message", + messageId: MessageId.make("message:held-item:reply"), + text: "Still watching", + streaming: true, + status: "running", + }), + ), + ); + yield* deliver( + itemEvent("event:held-item:tick", item("item:tool", { ...background, status: "running" })), + ); + assert.equal(shellReads.length, readsBeforeOutput); + + yield* deliver( + itemEvent("event:held-item:end", item("item:tool", { ...background, status: "completed" })), + ); + assert.deepEqual( + publications.map((publication) => publication.state?.phase), + ["running", "completed"], + ); + }).pipe(Effect.scoped, Effect.provide(projectionStoreMemoryLayer)), + ); }); describe.sequential("startup catch-up", () => { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 3768492127be..71e74d11b913 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -2,6 +2,7 @@ import type { EnvironmentId, OrchestrationV2DomainEvent, OrchestrationV2ThreadShell, + OrchestrationV2TurnItem, Project, ThreadId, } from "@t3tools/contracts"; @@ -11,6 +12,7 @@ import { type RelayAgentActivityState, } from "@t3tools/contracts/relay"; import { projectThreadAwarenessV2 } from "@t3tools/shared/agentAwareness"; +import { turnItemUpdateCanEndBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import { @@ -64,6 +66,15 @@ function eventThreadId(event: OrchestrationV2DomainEvent): ThreadId { return event.threadId; } +// The filter takes loosely typed events; a turn-item payload carries both fields. +function isTurnItemPayload( + payload: unknown, +): payload is Pick { + return ( + typeof payload === "object" && payload !== null && "type" in payload && "status" in payload + ); +} + export function shouldPublishAgentAwarenessEvent( event: Pick & { readonly payload?: unknown }, ): boolean { @@ -76,8 +87,10 @@ export function shouldPublishAgentAwarenessEvent( ) { return false; } - // projectThreadAwarenessV2 reads thread metadata, run status, and pending requests. - // Message bodies and tool progress cannot change the published activity. + // projectThreadAwarenessV2 reads thread metadata, run status, pending requests, + // and pending background work (a finished subagent, a cleared roster, or an + // ended background item can release a held completion). Message bodies and + // tool progress cannot change the published activity. switch (event.type) { case "thread.created": case "thread.archived": @@ -90,6 +103,8 @@ export function shouldPublishAgentAwarenessEvent( case "run.created": case "run.updated": case "runtime-request.updated": + case "subagent.updated": + case "provider-thread.updated": return true; case "thread.settled": case "thread.unsettled": @@ -108,14 +123,13 @@ export function shouldPublishAgentAwarenessEvent( case "run-attempt.created": case "run-attempt.updated": case "node.updated": - case "subagent.updated": case "provider-session.attached": case "provider-session.updated": case "provider-session.detached": - case "provider-thread.updated": case "provider-turn.updated": - case "message.updated": case "turn-item.updated": + return isTurnItemPayload(event.payload) && turnItemUpdateCanEndBackgroundWork(event.payload); + case "message.updated": case "plan.updated": case "checkpoint-scope.created": case "checkpoint.captured": diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 6db13a3e1e03..36709813ef0e 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -20,6 +20,7 @@ const state = vi.hoisted(() => ({ turnError: false, limited: false, subagent: false, + background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => "toast-1", @@ -70,6 +71,7 @@ function mockThreadShell() { latestVisibleMessage: null, latestUserMessageAt: null, hasActionableProposedPlan: false, + pendingBackgroundTasks: state.background, itemCount: 0, visibleItemCount: 0, createdAt: SHELL_NOW, @@ -151,6 +153,7 @@ beforeEach(() => { turnError: false, limited: false, subagent: false, + background: [], }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); vi.stubGlobal("window", new EventTarget()); @@ -248,6 +251,19 @@ describe("thread notifications", () => { }); }); + it("alerts when only a dev server is left running, not while a monitor can wake the agent", async () => { + await render(); + state.background = [{ taskId: "watch", kind: "monitor" }]; + await complete(); + expect(state.add).not.toHaveBeenCalled(); + state.background = [{ taskId: "dev", kind: "command" }]; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "Thread completed" }), + ); + }); + it("keeps background desktop alerts when in-app notifications are disabled", async () => { state.focused = false; state.inApp = false; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index dda9623aea6c..e59099d62d4f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -2,6 +2,7 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models"; import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as Option from "effect/Option"; import { CircleAlertIcon, @@ -125,10 +126,12 @@ function EnvironmentNotifications({ ? `${thread.latestRun?.runId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); + // Waiting only on commands (a dev server) is done; subagents and monitors wake the agent. + const settled = + status === "ready" || + (status === "waiting" && !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks)); const completion = - status === "ready" && - thread.latestRun?.status === "completed" && - Number.isFinite(completedAt) + settled && thread.latestRun?.status === "completed" && Number.isFinite(completedAt) ? completedAt : (prior?.completion ?? null); next.set(thread.id, { attention, completion }); diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index f82c320f777f..c31312646c02 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -23,7 +23,11 @@ describe("projectThreadAwarenessV2", () => { overrides: Partial< Pick< OrchestrationV2ThreadShell, - "activityRunStatus" | "status" | "pendingRuntimeRequest" | "lineage" + | "activityRunStatus" + | "status" + | "pendingBackgroundTasks" + | "pendingRuntimeRequest" + | "lineage" > > = {}, ) => ({ @@ -81,6 +85,27 @@ describe("projectThreadAwarenessV2", () => { ).toMatchObject({ phase: "running", headline: "Agent is working" }); }); + it.each([ + ["only a dev server", "completed", [{ taskId: "dev", kind: "command" }]], + ["a monitor", "running", [{ taskId: "watch", kind: "monitor" }]], + [ + "a dev server and a subagent", + "running", + [ + { taskId: "dev", kind: "command" }, + { taskId: "review", kind: "subagent" }, + ], + ], + ] as const)("reports a completed run waiting on %s as %s", (_case, phase, tasks) => { + expect( + projectThreadAwarenessV2({ + environmentId: "env-1" as EnvironmentId, + project, + thread: v2Thread({ status: "completed", pendingBackgroundTasks: tasks }), + }), + ).toMatchObject({ phase }); + }); + it("prioritizes V2 user-input requests", () => { expect( projectThreadAwarenessV2({ diff --git a/packages/shared/src/agentAwareness.ts b/packages/shared/src/agentAwareness.ts index 870299102038..bc4aaa781c56 100644 --- a/packages/shared/src/agentAwareness.ts +++ b/packages/shared/src/agentAwareness.ts @@ -6,6 +6,8 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { backgroundWorkHoldsCompletion } from "./orchestrationV2PendingBackgroundWork.ts"; + export type AgentAwarenessPhase = | "starting" | "running" @@ -44,6 +46,7 @@ export interface ProjectThreadAwarenessV2Input { | "id" | "lineage" | "modelSelection" + | "pendingBackgroundTasks" | "pendingRuntimeRequest" | "status" | "title" @@ -101,7 +104,10 @@ function resolveThreadAwarenessPhaseV2( case "waiting": return "running"; case "completed": - return "completed"; + // Work that will wake the agent keeps the run going; a dev server does not. + return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) + ? "running" + : "completed"; case "failed": return "failed"; case "idle": diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 9485a7a8292b..a766d5d30e43 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -1,5 +1,44 @@ import { describe, expect, it } from "vite-plus/test"; -import { derivePendingBackgroundWork } from "./orchestrationV2PendingBackgroundWork.ts"; +import type { OrchestrationV2PendingBackgroundTask } from "@t3tools/contracts"; +import { + backgroundWorkHoldsCompletion, + derivePendingBackgroundWork, + turnItemUpdateCanEndBackgroundWork, +} from "./orchestrationV2PendingBackgroundWork.ts"; + +describe("turnItemUpdateCanEndBackgroundWork", () => { + it.each([ + ["a finished subagent", true, { type: "subagent", status: "completed" }], + ["a failed background command", true, { type: "command_execution", status: "failed" }], + ["an idle dynamic tool", true, { type: "dynamic_tool", status: "idle" }], + ["a running command's output", false, { type: "command_execution", status: "running" }], + ["a waiting subagent", false, { type: "subagent", status: "waiting" }], + ["a finished assistant message", false, { type: "assistant_message", status: "completed" }], + ] as const)("%s: %s", (_case, canEnd, item) => { + expect(turnItemUpdateCanEndBackgroundWork(item)).toBe(canEnd); + }); +}); + +describe("backgroundWorkHoldsCompletion", () => { + const task = ( + taskId: string, + kind: OrchestrationV2PendingBackgroundTask["kind"], + ): OrchestrationV2PendingBackgroundTask => ({ taskId, kind }); + + it.each([ + ["nothing pending", false, []], + ["a dev server", false, [task("dev", "command")]], + ["two long-lived shells", false, [task("web", "command"), task("api", "command")]], + ["a subagent", true, [task("review", "subagent")]], + ["a monitor", true, [task("watch", "monitor")]], + ["a command and a monitor", true, [task("dev", "command"), task("watch", "monitor")]], + ["a command and a subagent", true, [task("dev", "command"), task("review", "subagent")]], + // Also what an unknown or missing kind decodes to. + ["work the provider cannot name", true, [task("opaque", "background_task")]], + ] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => { + expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds); + }); +}); describe("derivePendingBackgroundWork", () => { it("returns empty while the latest run is not settled", () => { diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 33b6e591f0c3..ee78736e5c88 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -48,6 +48,42 @@ void TERMINAL_RUN_STATUSES; export type PendingBackgroundWorkTask = OrchestrationV2PendingBackgroundTask; +/** + * Whether a turn-item update can end background work that a settled run is + * still waiting on: an item of a background type that is no longer active. + * Streaming output on a running item, and every other item type, cannot. + */ +export function turnItemUpdateCanEndBackgroundWork( + item: Pick, +): boolean { + return BACKGROUND_TURN_ITEM_TYPES.has(item.type) && !isOrchestrationV2WorkActive(item.status); +} + +/** + * Whether background work left behind by a completed root run holds back its + * completion alert (desktop/web notification and the mobile push). Commands, + * such as dev servers and other long-lived shells, do not: the agent is done + * and may leave them running for hours. Subagents and monitors do, because + * they wake the agent and it continues (#13625). Work the adapter cannot name, + * including kinds this build does not know, holds as the conservative choice. + */ +export function backgroundWorkHoldsCompletion( + tasks: ReadonlyArray>, +): boolean { + return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind)); +} + +function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean { + switch (kind) { + case "command": + return false; + case "subagent": + case "monitor": + case "background_task": + return true; + } +} + type PendingBackgroundWorkRun = Pick; type PendingBackgroundWorkProviderThread = Pick<