authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-06 00:32:28-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-06 00:32:28-04:00
loga2ca89aa10f13a2222e08afd98c66285121d5ba2
tree8e74d91573299f147977e350dcdbe7c13c7a9704
parent990bb0b689b23a33f8e53f12962fe2542b3f4005
signature Signed by PGP key B5690EEEBB952194

feat: native subagent & workflow observability (#5219)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

46 files changed, 6871 insertions(+), 71 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+36
...@@ -532,3 +532,39 @@ describe("buildThreadFeed", () => {...@@ -532,3 +532,39 @@ describe("buildThreadFeed", () => {
532 });532 });
533 });533 });
534});534});
535
536describe("quiet timeline: nested agents", () => {
537 it("keeps a nested agent's terminal row but hides its background work", () => {
538 const thread = makeThread({
539 id: ThreadId.make("thread-nested"),
540 projectId: ProjectId.make("project-1"),
541 title: "Nested agents",
542 activities: [
543 // A subagent's own shell: internal, covered by the owner's liveness.
544 makeActivity({
545 id: EventId.make("shell-done"),
546 kind: "task.completed",
547 summary: "Task completed",
548 createdAt: "2026-04-01T00:00:02.000Z",
549 payload: { taskId: "sh-1", agentId: "owner", agentKind: "background" },
550 }),
551 // A nested AGENT's completion: mobile has no Agents sheet, so this
552 // terminal row is the only signal it ever finished.
553 makeActivity({
554 id: EventId.make("nested-done"),
555 kind: "task.completed",
556 summary: "Task completed",
557 createdAt: "2026-04-01T00:00:03.000Z",
558 payload: { taskId: "n-1", agentId: "owner", agentKind: "agent" },
559 }),
560 ],
561 });
562
563 const feed = buildThreadFeed(thread);
564 const ids = feed.flatMap((entry) =>
565 entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [],
566 );
567 expect(ids).toContain("nested-done");
568 expect(ids).not.toContain("shell-done");
569 });
570});
apps/mobile/src/lib/threadActivity.ts+93-1
...@@ -80,6 +80,8 @@ interface WorkLogEntry {...@@ -80,6 +80,8 @@ interface WorkLogEntry {
80interface DerivedWorkLogEntry extends WorkLogEntry {80interface DerivedWorkLogEntry extends WorkLogEntry {
81 activityKind: OrchestrationThreadActivity["kind"];81 activityKind: OrchestrationThreadActivity["kind"];
82 collapseKey?: string;82 collapseKey?: string;
83 /** Grouping key for subagent lifecycle rows (one row per agent). */
84 taskId?: string;
83}85}
8486
85type RawThreadFeedEntry =87type RawThreadFeedEntry =
...@@ -235,6 +237,63 @@ function resolvePendingUserInputAnswer(...@@ -235,6 +237,63 @@ function resolvePendingUserInputAnswer(
235 return normalizeDraftAnswer(draft?.selectedOptionLabel);237 return normalizeDraftAnswer(draft?.selectedOptionLabel);
236}238}
237239
240/** Codex children settle via task.updated (idle/failed/interrupted), never
241 * task.completed — these rows are mobile's only terminal signal for them. */
242const MOBILE_TERMINAL_UPDATE_STATUSES: ReadonlySet<string> = new Set([
243 "idle",
244 "completed",
245 "failed",
246 "cancelled",
247 "interrupted",
248]);
249
250function isTerminalBypassUpdate(activity: OrchestrationThreadActivity): boolean {
251 if (activity.kind !== "task.updated") {
252 return false;
253 }
254 const payload =
255 activity.payload && typeof activity.payload === "object"
256 ? (activity.payload as Record<string, unknown>)
257 : null;
258 return (
259 payload?.timelineBypass === true &&
260 typeof payload.status === "string" &&
261 MOBILE_TERMINAL_UPDATE_STATUSES.has(payload.status)
262 );
263}
264
265/**
266 * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal
267 * activity lives in the Agents sheet, not the work log. Terminal rows are
268 * kept — with no Agents surface on mobile they are the terminal signal
269 * (a surface that hides rows must keep its own terminal signal). That means
270 * task.completed (Claude) AND terminal bypassed task.updated (Codex, whose
271 * children never emit task.completed — review finding).
272 */
273function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
274 const payload =
275 activity.payload && typeof activity.payload === "object"
276 ? (activity.payload as Record<string, unknown>)
277 : null;
278 if (!payload) {
279 return false;
280 }
281 const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalBypassUpdate(activity);
282 if (payload.timelineBypass === true && !isTerminalTaskRow) {
283 return true;
284 }
285 // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is
286 // the only signal mobile gets (no Agents sheet), so it stays. Only an
287 // agent's own background work (stamped "background") is internal — same
288 // rule as web (review finding: hiding on agentId alone dropped nested
289 // completions with no replacement UI).
290 const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
291 if (!ownedByAgent) {
292 return false;
293 }
294 return !(isTerminalTaskRow && payload.agentKind === "agent");
295}
296
238function deriveWorkLogEntries(297function deriveWorkLogEntries(
239 activities: ReadonlyArray<OrchestrationThreadActivity>,298 activities: ReadonlyArray<OrchestrationThreadActivity>,
240): DerivedWorkLogEntry[] {299): DerivedWorkLogEntry[] {
...@@ -243,9 +302,13 @@ function deriveWorkLogEntries(...@@ -243,9 +302,13 @@ function deriveWorkLogEntries(
243 for (const activity of ordered) {302 for (const activity of ordered) {
244 if (activity.kind === "tool.started") continue;303 if (activity.kind === "tool.started") continue;
245 if (activity.kind === "task.started") continue;304 if (activity.kind === "task.started") continue;
305 // Terminal bypassed updates pass: Codex children's only terminal signal.
306 if (activity.kind === "task.updated" && !isTerminalBypassUpdate(activity)) continue;
307 if (activity.kind === "tool.progress") continue;
246 if (activity.kind === "context-window.updated") continue;308 if (activity.kind === "context-window.updated") continue;
247 if (activity.summary === "Checkpoint captured") continue;309 if (activity.summary === "Checkpoint captured") continue;
248 if (isPlanBoundaryToolActivity(activity)) continue;310 if (isPlanBoundaryToolActivity(activity)) continue;
311 if (isAgentInternalActivity(activity)) continue;
249 entries.push(toDerivedWorkLogEntry(activity));312 entries.push(toDerivedWorkLogEntry(activity));
250 }313 }
251 return collapseDerivedWorkLogEntries(entries);314 return collapseDerivedWorkLogEntries(entries);
...@@ -271,7 +334,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -271,7 +334,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
271 const commandPreview = extractToolCommand(payload);334 const commandPreview = extractToolCommand(payload);
272 const changedFiles = extractChangedFiles(payload);335 const changedFiles = extractChangedFiles(payload);
273 const title = extractToolTitle(payload);336 const title = extractToolTitle(payload);
274 const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";337 // task.updated included: terminal bypassed updates (Codex children's only
338 // terminal signal) must carry task identity so they collapse per child
339 // instead of stacking anonymous "Task idle" rows.
340 const isTaskActivity =
341 activity.kind === "task.progress" ||
342 activity.kind === "task.completed" ||
343 activity.kind === "task.updated";
275 const taskSummary =344 const taskSummary =
276 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0345 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
277 ? payload.summary346 ? payload.summary
...@@ -284,10 +353,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -284,10 +353,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
284 ? payload.detail353 ? payload.detail
285 : null;354 : null;
286 const taskLabel = taskSummary || taskDetailAsLabel;355 const taskLabel = taskSummary || taskDetailAsLabel;
356 const taskId =
357 isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
358 ? payload.taskId
359 : undefined;
287 const entry: DerivedWorkLogEntry = {360 const entry: DerivedWorkLogEntry = {
288 id: activity.id,361 id: activity.id,
289 createdAt: activity.createdAt,362 createdAt: activity.createdAt,
290 turnId: activity.turnId,363 turnId: activity.turnId,
364 ...(taskId ? { taskId } : {}),
291 label: taskLabel || activity.summary,365 label: taskLabel || activity.summary,
292 tone:366 tone:
293 activity.kind === "task.progress"367 activity.kind === "task.progress"
...@@ -352,7 +426,25 @@ function collapseDerivedWorkLogEntries(...@@ -352,7 +426,25 @@ function collapseDerivedWorkLogEntries(
352 entries: ReadonlyArray<DerivedWorkLogEntry>,426 entries: ReadonlyArray<DerivedWorkLogEntry>,
353): DerivedWorkLogEntry[] {427): DerivedWorkLogEntry[] {
354 const collapsed: DerivedWorkLogEntry[] = [];428 const collapsed: DerivedWorkLogEntry[] = [];
429 // Subagent rows collapse by identity, not adjacency (quiet-timeline
430 // guarantee; mirrors web's session-logic).
431 const taskRowIndex = new Map<string, number>();
355 for (const entry of entries) {432 for (const entry of entries) {
433 const isTaskRow =
434 entry.taskId !== undefined &&
435 (entry.activityKind === "task.progress" ||
436 entry.activityKind === "task.completed" ||
437 entry.activityKind === "task.updated");
438 if (isTaskRow && entry.taskId !== undefined) {
439 const existingIndex = taskRowIndex.get(entry.taskId);
440 if (existingIndex !== undefined) {
441 collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry);
442 continue;
443 }
444 taskRowIndex.set(entry.taskId, collapsed.length);
445 collapsed.push(entry);
446 continue;
447 }
356 const previous = collapsed.at(-1);448 const previous = collapsed.at(-1);
357 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {449 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
358 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);450 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
apps/server/integration/OrchestrationEngineHarness.integration.ts+2-1
...@@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe...@@ -50,6 +50,7 @@ import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityRe
50import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";50import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
52import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";52import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
53import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
53import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";54import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
54import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";55import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
55import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";56import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
...@@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = (
305 checkpointStoreLayer,306 checkpointStoreLayer,
306 providerLayer,307 providerLayer,
307 RuntimeReceiptBusTest,308 RuntimeReceiptBusTest,
308 );309 ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
309 const serverSettingsLayer = ServerSettingsService.layerTest();310 const serverSettingsLayer = ServerSettingsService.layerTest();
310 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(311 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
311 Layer.provideMerge(runtimeServicesLayer),312 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];...@@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
22 */22 */
23export const RPC_REQUIRED_SCOPES = {23export const RPC_REQUIRED_SCOPES = {
24 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,24 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
25 [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
25 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,26 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
26 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,27 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
27 [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,28 [ORCHESTRATION_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
apps/server/src/orchestration/ActivityPayloadProjection.test.ts created+63
...@@ -0,0 +1,63 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { OrchestrationThreadActivity } from "@t3tools/contracts";
3import { projectActivityPayload } from "./ActivityPayloadProjection.ts";
4
5function activity(payload: Record<string, unknown>): OrchestrationThreadActivity {
6 return {
7 id: "activity-1",
8 tone: "tool",
9 kind: "tool.completed",
10 summary: "Tool",
11 payload,
12 turnId: null,
13 createdAt: "2026-08-01T10:00:00.000Z",
14 } as unknown as OrchestrationThreadActivity;
15}
16
17/**
18 * Wire-survival regression: the slimming pass rewrites payload.data but must
19 * never strip the top-level per-agent fields the subagent fold depends on.
20 * If slimming ever moves to an allowlist over the whole payload, these
21 * assertions are the tripwire.
22 */
23describe("projectActivityPayload agent-field survival", () => {
24 it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
25 const projected = projectActivityPayload(
26 activity({
27 itemType: "command_execution",
28 agentId: "task-123",
29 parentToolUseId: "toolu_abc",
30 data: {
31 toolName: "Bash",
32 input: { command: "ls" },
33 command: "ls",
34 rawOutput: { content: "x".repeat(10) },
35 somethingClientNeverReads: { big: "blob" },
36 },
37 }),
38 );
39 const payload = projected.payload as Record<string, unknown>;
40 expect(payload.agentId).toBe("task-123");
41 expect(payload.parentToolUseId).toBe("toolu_abc");
42 // Slimming itself still applies to data.
43 const data = payload.data as Record<string, unknown>;
44 expect(data.somethingClientNeverReads).toBeUndefined();
45 });
46
47 it("passes task lifecycle payloads (no data field) through untouched", () => {
48 const source = activity({
49 taskId: "task-9",
50 title: "Audit auth",
51 role: "explorer",
52 model: "opus",
53 effort: "high",
54 workflowName: "audit-flow",
55 phases: [{ index: 0, title: "Audit" }],
56 typedUsage: { totalTokens: 1200 },
57 runHandles: { runId: "run-1", scriptPath: "/tmp/wf.js" },
58 timelineBypass: true,
59 });
60 const projected = projectActivityPayload(source);
61 expect(projected.payload).toEqual(source.payload);
62 });
63});
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3
...@@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts";...@@ -39,6 +39,7 @@ import { CheckpointReactorLive } from "./CheckpointReactor.ts";
39import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";39import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
40import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";40import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
41import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";41import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
42import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
42import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";43import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
43import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";44import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
44import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";45import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
...@@ -294,6 +295,7 @@ describe("CheckpointReactor", () => {...@@ -294,6 +295,7 @@ describe("CheckpointReactor", () => {
294 );295 );
295 const orchestrationLayer = OrchestrationEngineLive.pipe(296 const orchestrationLayer = OrchestrationEngineLive.pipe(
296 Layer.provide(OrchestrationProjectionSnapshotQueryLive),297 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
298 Layer.provide(ThreadBackgroundLiveness.layer),
297 Layer.provide(OrchestrationProjectionPipelineLive),299 Layer.provide(OrchestrationProjectionPipelineLive),
298 Layer.provide(OrchestrationEventStoreLive),300 Layer.provide(OrchestrationEventStoreLive),
299 Layer.provide(OrchestrationCommandReceiptRepositoryLive),301 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -301,6 +303,7 @@ describe("CheckpointReactor", () => {...@@ -301,6 +303,7 @@ describe("CheckpointReactor", () => {
301 Layer.provide(SqlitePersistenceMemory),303 Layer.provide(SqlitePersistenceMemory),
302 );304 );
303 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(305 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
306 Layer.provide(ThreadBackgroundLiveness.layer),
304 Layer.provide(RepositoryIdentityResolver.layer),307 Layer.provide(RepositoryIdentityResolver.layer),
305 Layer.provide(SqlitePersistenceMemory),308 Layer.provide(SqlitePersistenceMemory),
306 );309 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5
...@@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes...@@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
31import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";31import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";32import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
34import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";35import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
35import {36import {
36 OrchestrationProjectionPipeline,37 OrchestrationProjectionPipeline,
...@@ -55,6 +56,7 @@ async function createOrchestrationSystem() {...@@ -55,6 +56,7 @@ async function createOrchestrationSystem() {
55 ),56 ),
56 OrchestrationProjectionSnapshotQueryLive,57 OrchestrationProjectionSnapshotQueryLive,
57 ).pipe(58 ).pipe(
59 Layer.provide(ThreadBackgroundLiveness.layer),
58 Layer.provide(OrchestrationEventStoreLive),60 Layer.provide(OrchestrationEventStoreLive),
59 Layer.provide(OrchestrationCommandReceiptRepositoryLive),61 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
60 Layer.provide(RepositoryIdentityResolver.layer),62 Layer.provide(RepositoryIdentityResolver.layer),
...@@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => {...@@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => {
817 const runtime = ManagedRuntime.make(819 const runtime = ManagedRuntime.make(
818 OrchestrationEngineLive.pipe(820 OrchestrationEngineLive.pipe(
819 Layer.provide(OrchestrationProjectionSnapshotQueryLive),821 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
822 Layer.provide(ThreadBackgroundLiveness.layer),
820 Layer.provide(OrchestrationProjectionPipelineLive),823 Layer.provide(OrchestrationProjectionPipelineLive),
821 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),824 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
822 Layer.provide(OrchestrationCommandReceiptRepositoryLive),825 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => {...@@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => {
922 const runtime = ManagedRuntime.make(925 const runtime = ManagedRuntime.make(
923 OrchestrationEngineLive.pipe(926 OrchestrationEngineLive.pipe(
924 Layer.provide(OrchestrationProjectionSnapshotQueryLive),927 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
928 Layer.provide(ThreadBackgroundLiveness.layer),
925 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),929 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
926 Layer.provide(OrchestrationEventStoreLive),930 Layer.provide(OrchestrationEventStoreLive),
927 Layer.provide(OrchestrationCommandReceiptRepositoryLive),931 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => {...@@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => {
1065 const runtime = ManagedRuntime.make(1069 const runtime = ManagedRuntime.make(
1066 OrchestrationEngineLive.pipe(1070 OrchestrationEngineLive.pipe(
1067 Layer.provide(OrchestrationProjectionSnapshotQueryLive),1071 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
1072 Layer.provide(ThreadBackgroundLiveness.layer),
1068 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),1073 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
1069 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),1074 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
1070 Layer.provide(OrchestrationCommandReceiptRepositoryLive),1075 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 OrchestrationProjectionPipelineLive,31 OrchestrationProjectionPipelineLive,
32} from "./ProjectionPipeline.ts";32} from "./ProjectionPipeline.ts";
33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";33import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
34import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";35import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
35import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";36import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
36import { ServerConfig } from "../../config.ts";37import { ServerConfig } from "../../config.ts";
...@@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta...@@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
2664const engineLayer = it.layer(2665const engineLayer = it.layer(
2665 OrchestrationEngineLive.pipe(2666 OrchestrationEngineLive.pipe(
2666 Layer.provide(OrchestrationProjectionSnapshotQueryLive),2667 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
2668 Layer.provide(ThreadBackgroundLiveness.layer),
2667 Layer.provide(OrchestrationProjectionPipelineLive),2669 Layer.provide(OrchestrationProjectionPipelineLive),
2668 Layer.provide(OrchestrationEventStoreLive),2670 Layer.provide(OrchestrationEventStoreLive),
2669 Layer.provide(OrchestrationCommandReceiptRepositoryLive),2671 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4
...@@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";...@@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
17import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";17import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
18import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";18import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
19import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";19import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
20import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
20import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";21import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2122
22const asProjectId = (value: string): ProjectId => ProjectId.make(value);23const asProjectId = (value: string): ProjectId => ProjectId.make(value);
...@@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val...@@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
2728
28const projectionSnapshotLayer = it.layer(29const projectionSnapshotLayer = it.layer(
29 OrchestrationProjectionSnapshotQueryLive.pipe(30 OrchestrationProjectionSnapshotQueryLive.pipe(
31 Layer.provide(ThreadBackgroundLiveness.layer),
30 Layer.provideMerge(RepositoryIdentityResolver.layer),32 Layer.provideMerge(RepositoryIdentityResolver.layer),
31 Layer.provideMerge(SqlitePersistenceMemory),33 Layer.provideMerge(SqlitePersistenceMemory),
32 Layer.provideMerge(NodeServices.layer),34 Layer.provideMerge(NodeServices.layer),
...@@ -443,6 +445,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -443,6 +445,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
443 hasPendingApprovals: true,445 hasPendingApprovals: true,
444 hasPendingUserInput: false,446 hasPendingUserInput: false,
445 hasActionableProposedPlan: false,447 hasActionableProposedPlan: false,
448 backgroundLiveness: null,
446 },449 },
447 ]);450 ]);
448451
...@@ -1825,6 +1828,7 @@ it.effect(...@@ -1825,6 +1828,7 @@ it.effect(
1825 () => {1828 () => {
1826 const resolveCalls: string[] = [];1829 const resolveCalls: string[] = [];
1827 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(1830 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
1831 Layer.provide(ThreadBackgroundLiveness.layer),
1828 Layer.provideMerge(1832 Layer.provideMerge(
1829 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {1833 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
1830 resolve: (cwd: string) =>1834 resolve: (cwd: string) =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11
...@@ -43,6 +43,7 @@ import {...@@ -43,6 +43,7 @@ import {
43 type ProjectionRepositoryError,43 type ProjectionRepositoryError,
44} from "../../persistence/Errors.ts";44} from "../../persistence/Errors.ts";
45import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";45import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
46import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
46import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";47import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
47import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";48import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
48import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";49import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
...@@ -308,6 +309,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st...@@ -308,6 +309,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
308}309}
309310
310const makeProjectionSnapshotQuery = Effect.gen(function* () {311const makeProjectionSnapshotQuery = Effect.gen(function* () {
312 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
311 const sql = yield* SqlClient.SqlClient;313 const sql = yield* SqlClient.SqlClient;
312 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;314 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
313 const repositoryIdentityResolutionConcurrency = 4;315 const repositoryIdentityResolutionConcurrency = 4;
...@@ -1678,6 +1680,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1678,6 +1680,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1678 hasPendingApprovals: row.pendingApprovalCount > 0,1680 hasPendingApprovals: row.pendingApprovalCount > 0,
1679 hasPendingUserInput: row.pendingUserInputCount > 0,1681 hasPendingUserInput: row.pendingUserInputCount > 0,
1680 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,1682 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1683 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
1684 row.threadId,
1685 ),
1681 } satisfies OrchestrationThreadShell)1686 } satisfies OrchestrationThreadShell)
1682 : Result.failVoid,1687 : Result.failVoid,
1683 ),1688 ),
...@@ -1818,6 +1823,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -1818,6 +1823,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
1818 hasPendingApprovals: row.pendingApprovalCount > 0,1823 hasPendingApprovals: row.pendingApprovalCount > 0,
1819 hasPendingUserInput: row.pendingUserInputCount > 0,1824 hasPendingUserInput: row.pendingUserInputCount > 0,
1820 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,1825 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1826 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
1827 row.threadId,
1828 ),
1821 }),1829 }),
1822 ),1830 ),
1823 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",1831 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
...@@ -2090,6 +2098,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2090,6 +2098,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2090 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,2098 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
2091 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,2099 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,
2092 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,2100 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,
2101 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2102 threadRow.value.threadId,
2103 ),
2093 } satisfies OrchestrationThreadShell);2104 } satisfies OrchestrationThreadShell);
2094 });2105 });
20952106
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3
...@@ -48,6 +48,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes...@@ -48,6 +48,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
48import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";48import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
49import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";49import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
50import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";50import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
51import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
51import {52import {
52 providerErrorLabel,53 providerErrorLabel,
53 providerErrorLabelFromInstanceHint,54 providerErrorLabelFromInstanceHint,
...@@ -345,6 +346,7 @@ describe("ProviderCommandReactor", () => {...@@ -345,6 +346,7 @@ describe("ProviderCommandReactor", () => {
345346
346 const orchestrationLayer = OrchestrationEngineLive.pipe(347 const orchestrationLayer = OrchestrationEngineLive.pipe(
347 Layer.provide(OrchestrationProjectionSnapshotQueryLive),348 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
349 Layer.provide(ThreadBackgroundLiveness.layer),
348 Layer.provide(OrchestrationProjectionPipelineLive),350 Layer.provide(OrchestrationProjectionPipelineLive),
349 Layer.provide(OrchestrationEventStoreLive),351 Layer.provide(OrchestrationEventStoreLive),
350 Layer.provide(OrchestrationCommandReceiptRepositoryLive),352 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
...@@ -352,6 +354,7 @@ describe("ProviderCommandReactor", () => {...@@ -352,6 +354,7 @@ describe("ProviderCommandReactor", () => {
352 Layer.provide(SqlitePersistenceMemory),354 Layer.provide(SqlitePersistenceMemory),
353 );355 );
354 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(356 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
357 Layer.provide(ThreadBackgroundLiveness.layer),
355 Layer.provide(RepositoryIdentityResolver.layer),358 Layer.provide(RepositoryIdentityResolver.layer),
356 Layer.provide(SqlitePersistenceMemory),359 Layer.provide(SqlitePersistenceMemory),
357 );360 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+10-3
...@@ -44,6 +44,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes...@@ -44,6 +44,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
44import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";44import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
45import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";45import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
46import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";46import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
47import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
47import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";48import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
48import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";49import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
49import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";50import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
...@@ -237,6 +238,9 @@ describe("ProviderRuntimeIngestion", () => {...@@ -237,6 +238,9 @@ describe("ProviderRuntimeIngestion", () => {
237 const layer = ProviderRuntimeIngestionLive.pipe(238 const layer = ProviderRuntimeIngestionLive.pipe(
238 Layer.provideMerge(orchestrationLayer),239 Layer.provideMerge(orchestrationLayer),
239 Layer.provideMerge(projectionSnapshotLayer),240 Layer.provideMerge(projectionSnapshotLayer),
241 // Single shared liveness instance across ingestion (writer), the
242 // engine, and the snapshot query (reader).
243 Layer.provideMerge(ThreadBackgroundLiveness.layer),
240 Layer.provideMerge(SqlitePersistenceMemory),244 Layer.provideMerge(SqlitePersistenceMemory),
241 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),245 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
242 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),246 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
...@@ -3143,7 +3147,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3143,7 +3147,8 @@ describe("ProviderRuntimeIngestion", () => {
3143 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started",3147 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started",
3144 );3148 );
3145 const progress = thread.activities.find(3149 const progress = thread.activities.find(
3146 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-progress",3150 (activity: ProviderRuntimeTestActivity) =>
3151 activity.id === "task-progress:thread-1:turn-task-1",
3147 );3152 );
3148 const completed = thread.activities.find(3153 const completed = thread.activities.find(
3149 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed",3154 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed",
...@@ -3227,7 +3232,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3227,7 +3232,8 @@ describe("ProviderRuntimeIngestion", () => {
3227 );3232 );
32283233
3229 const progress = thread.activities.find(3234 const progress = thread.activities.find(
3230 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-progress",3235 (activity: ProviderRuntimeTestActivity) =>
3236 activity.id === "task-progress:thread-1:named-task-1",
3231 );3237 );
3232 const completed = thread.activities.find(3238 const completed = thread.activities.find(
3233 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed",3239 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed",
...@@ -3318,7 +3324,8 @@ describe("ProviderRuntimeIngestion", () => {...@@ -3318,7 +3324,8 @@ describe("ProviderRuntimeIngestion", () => {
33183324
3319 await waitForThread(harness.readModel, (entry) =>3325 await waitForThread(harness.readModel, (entry) =>
3320 entry.activities.some(3326 entry.activities.some(
3321 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-progress",3327 (activity: ProviderRuntimeTestActivity) =>
3328 activity.id === "task-progress:thread-1:swept-task-1",
3322 ),3329 ),
3323 );3330 );
33243331
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+176-1
...@@ -7,6 +7,8 @@ import {...@@ -7,6 +7,8 @@ import {
7 type OrchestrationMessage,7 type OrchestrationMessage,
8 type OrchestrationProposedPlanId,8 type OrchestrationProposedPlanId,
9 CheckpointRef,9 CheckpointRef,
10 classifyTaskAgentKind,
11 EventId,
10 isToolLifecycleItemType,12 isToolLifecycleItemType,
11 ThreadId,13 ThreadId,
12 type ThreadTokenUsageSnapshot,14 type ThreadTokenUsageSnapshot,
...@@ -32,6 +34,7 @@ import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionT...@@ -32,6 +34,7 @@ import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionT
32import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";34import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
33import { isGitRepository } from "../../git/Utils.ts";35import { isGitRepository } from "../../git/Utils.ts";
34import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";36import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
37import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
35import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";38import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
36import {39import {
37 ProviderRuntimeIngestionService,40 ProviderRuntimeIngestionService,
...@@ -307,6 +310,52 @@ function requestKindFromCanonicalRequestType(...@@ -307,6 +310,52 @@ function requestKindFromCanonicalRequestType(
307 }310 }
308}311}
309312
313/**
314 * Copies the optional TaskAgentLinkage bundle from a task.* runtime payload
315 * into the persisted activity payload. Identity fields ride on every row so
316 * client folds survive activity retention; absent fields stay absent.
317 */
318function taskLinkageActivityFields(payload: Record<string, unknown>): Record<string, unknown> {
319 const fields: Record<string, unknown> = {
320 // Server-stamped classification: persisted rows are self-describing, so
321 // clients trust the stamp instead of re-deriving agent-vs-background
322 // from taskType denylists and marker heuristics (legacy rows without a
323 // stamp keep the client fallback).
324 agentKind: classifyTaskAgentKind({
325 taskType: typeof payload.taskType === "string" ? payload.taskType : undefined,
326 agentId: typeof payload.agentId === "string" ? payload.agentId : undefined,
327 }),
328 };
329 for (const key of [
330 "taskType",
331 "agentId",
332 "title",
333 "role",
334 "model",
335 "effort",
336 "toolUseId",
337 "parentAgentId",
338 "workflowName",
339 "agentIndex",
340 "phaseIndex",
341 "phaseTitle",
342 "phases",
343 "attempt",
344 "runHandles",
345 "outputFile",
346 "agentPath",
347 "timelineBypass",
348 "typedUsage",
349 "status",
350 "error",
351 ] as const) {
352 if (payload[key] !== undefined) {
353 fields[key] = payload[key];
354 }
355 }
356 return fields;
357}
358
310export function runtimeEventToActivities(359export function runtimeEventToActivities(
311 event: ProviderRuntimeEvent,360 event: ProviderRuntimeEvent,
312 taskTitle?: string,361 taskTitle?: string,
...@@ -505,6 +554,7 @@ export function runtimeEventToActivities(...@@ -505,6 +554,7 @@ export function runtimeEventToActivities(
505 ...(event.payload.description554 ...(event.payload.description
506 ? { detail: truncateDetail(event.payload.description) }555 ? { detail: truncateDetail(event.payload.description) }
507 : {}),556 : {}),
557 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
508 },558 },
509 turnId: toTurnId(event.turnId) ?? null,559 turnId: toTurnId(event.turnId) ?? null,
510 ...maybeSequence,560 ...maybeSequence,
...@@ -515,7 +565,16 @@ export function runtimeEventToActivities(...@@ -515,7 +565,16 @@ export function runtimeEventToActivities(
515 case "task.progress": {565 case "task.progress": {
516 return [566 return [
517 {567 {
518 id: event.eventId,568 // Stable per-task id: progress is "latest state", not history, so
569 // each tick REPLACES the last via the activity upsert (PK + the
570 // replace-by-id apply in projector and client reducer). Keeps one
571 // progress row per task instead of thousands, so a large fleet's
572 // ticks can no longer evict its own start/terminal rows out of
573 // the 500-row retention window. Thread-scoped: activity_id is a
574 // GLOBAL primary key and Claude task ids are session-local, so a
575 // bare taskId could collide across threads and steal another
576 // thread's row (review finding).
577 id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
519 createdAt: event.createdAt,578 createdAt: event.createdAt,
520 tone: "info",579 tone: "info",
521 kind: "task.progress",580 kind: "task.progress",
...@@ -532,6 +591,71 @@ export function runtimeEventToActivities(...@@ -532,6 +591,71 @@ export function runtimeEventToActivities(
532 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),591 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
533 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),592 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
534 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),593 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
594 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
595 },
596 turnId: toTurnId(event.turnId) ?? null,
597 ...maybeSequence,
598 },
599 ];
600 }
601
602 case "task.updated": {
603 return [
604 {
605 id: event.eventId,
606 createdAt: event.createdAt,
607 tone: event.payload.status === "failed" ? "error" : "info",
608 kind: "task.updated",
609 summary:
610 event.payload.status === "failed"
611 ? "Task failed"
612 : event.payload.status
613 ? `Task ${event.payload.status}`
614 : "Task updated",
615 payload: {
616 taskId: event.payload.taskId,
617 ...(event.payload.description
618 ? { detail: truncateDetail(event.payload.description) }
619 : {}),
620 ...(event.payload.endedAt ? { endedAt: event.payload.endedAt } : {}),
621 ...(event.payload.isBackgrounded !== undefined
622 ? { isBackgrounded: event.payload.isBackgrounded }
623 : {}),
624 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
625 },
626 turnId: toTurnId(event.turnId) ?? null,
627 ...maybeSequence,
628 },
629 ];
630 }
631
632 case "tool.progress": {
633 // Only agent-owned heartbeats are persisted: they feed the owning
634 // agent's activity line. Parent-conversation tool progress stays
635 // ephemeral (item lifecycle already covers it).
636 if (event.payload.taskId === undefined) {
637 return [];
638 }
639 return [
640 {
641 // Same stable-id treatment as task.progress: a heartbeat is
642 // "what is this agent doing right now", so one row per task
643 // (thread-scoped for the same global-PK collision reason).
644 id: EventId.make(`tool-progress:${event.threadId}:${event.payload.taskId}`),
645 createdAt: event.createdAt,
646 tone: "info",
647 kind: "tool.progress",
648 summary: event.payload.toolName ?? "Tool progress",
649 payload: {
650 taskId: event.payload.taskId,
651 ...(event.payload.toolName ? { toolName: event.payload.toolName } : {}),
652 ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}),
653 ...(event.payload.elapsedSeconds !== undefined
654 ? { elapsedSeconds: event.payload.elapsedSeconds }
655 : {}),
656 ...(event.payload.parentToolUseId
657 ? { parentToolUseId: event.payload.parentToolUseId }
658 : {}),
535 },659 },
536 turnId: toTurnId(event.turnId) ?? null,660 turnId: toTurnId(event.turnId) ?? null,
537 ...maybeSequence,661 ...maybeSequence,
...@@ -565,6 +689,7 @@ export function runtimeEventToActivities(...@@ -565,6 +689,7 @@ export function runtimeEventToActivities(
565 }689 }
566 : {}),690 : {}),
567 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),691 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
692 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
568 },693 },
569 turnId: toTurnId(event.turnId) ?? null,694 turnId: toTurnId(event.turnId) ?? null,
570 ...maybeSequence,695 ...maybeSequence,
...@@ -630,6 +755,10 @@ export function runtimeEventToActivities(...@@ -630,6 +755,10 @@ export function runtimeEventToActivities(
630 ...(event.payload.status ? { status: event.payload.status } : {}),755 ...(event.payload.status ? { status: event.payload.status } : {}),
631 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),756 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
632 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),757 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
758 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
759 ...(event.payload.parentToolUseId
760 ? { parentToolUseId: event.payload.parentToolUseId }
761 : {}),
633 },762 },
634 turnId: toTurnId(event.turnId) ?? null,763 turnId: toTurnId(event.turnId) ?? null,
635 ...maybeSequence,764 ...maybeSequence,
...@@ -652,6 +781,10 @@ export function runtimeEventToActivities(...@@ -652,6 +781,10 @@ export function runtimeEventToActivities(
652 itemType: event.payload.itemType,781 itemType: event.payload.itemType,
653 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),782 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
654 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),783 ...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
784 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
785 ...(event.payload.parentToolUseId
786 ? { parentToolUseId: event.payload.parentToolUseId }
787 : {}),
655 },788 },
656 turnId: toTurnId(event.turnId) ?? null,789 turnId: toTurnId(event.turnId) ?? null,
657 ...maybeSequence,790 ...maybeSequence,
...@@ -673,6 +806,10 @@ export function runtimeEventToActivities(...@@ -673,6 +806,10 @@ export function runtimeEventToActivities(
673 payload: {806 payload: {
674 itemType: event.payload.itemType,807 itemType: event.payload.itemType,
675 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),808 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
809 ...(event.payload.agentId ? { agentId: event.payload.agentId } : {}),
810 ...(event.payload.parentToolUseId
811 ? { parentToolUseId: event.payload.parentToolUseId }
812 : {}),
676 },813 },
677 turnId: toTurnId(event.turnId) ?? null,814 turnId: toTurnId(event.turnId) ?? null,
678 ...maybeSequence,815 ...maybeSequence,
...@@ -688,6 +825,7 @@ export function runtimeEventToActivities(...@@ -688,6 +825,7 @@ export function runtimeEventToActivities(
688}825}
689826
690const make = Effect.gen(function* () {827const make = Effect.gen(function* () {
828 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
691 const crypto = yield* Crypto.Crypto;829 const crypto = yield* Crypto.Crypto;
692 const orchestrationEngine = yield* OrchestrationEngineService;830 const orchestrationEngine = yield* OrchestrationEngineService;
693 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;831 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
...@@ -1756,6 +1894,43 @@ const make = Effect.gen(function* () {...@@ -1756,6 +1894,43 @@ const make = Effect.gen(function* () {
1756 yield* rememberTaskDescription(thread.id, event.payload.taskId, description);1894 yield* rememberTaskDescription(thread.id, event.payload.taskId, description);
1757 }1895 }
1758 }1896 }
1897 // Sidebar background liveness: fed from the same lifecycle stream,
1898 // read by the shell query at mapping time (no persistence).
1899 switch (event.type) {
1900 case "task.started":
1901 case "task.progress":
1902 case "task.updated":
1903 case "task.completed": {
1904 const payload = event.payload as {
1905 taskId: string;
1906 taskType?: string;
1907 status?: string;
1908 agentId?: string;
1909 };
1910 threadBackgroundLiveness.recordTaskLiveness({
1911 threadId: thread.id,
1912 taskId: payload.taskId,
1913 taskType: payload.taskType,
1914 status: payload.status,
1915 agentId: payload.agentId,
1916 kind:
1917 event.type === "task.started"
1918 ? "started"
1919 : event.type === "task.progress"
1920 ? "progress"
1921 : event.type === "task.updated"
1922 ? "updated"
1923 : "completed",
1924 });
1925 break;
1926 }
1927 case "session.exited":
1928 threadBackgroundLiveness.clearThreadLiveness(thread.id);
1929 break;
1930 default:
1931 break;
1932 }
1933
1759 let taskTitle: string | undefined;1934 let taskTitle: string | undefined;
1760 if (event.type === "task.completed") {1935 if (event.type === "task.completed") {
1761 taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId);1936 taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId);
apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts created+173
...@@ -0,0 +1,173 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
3
4describe("ThreadBackgroundLiveness", () => {
5 it("agents present as working; monitors as monitoring; agents win", () => {
6 const liveness = ThreadBackgroundLiveness.make();
7 const threadId = "t-live-1";
8 liveness.recordTaskLiveness({
9 threadId,
10 taskId: "m1",
11 taskType: "local_bash",
12 status: undefined,
13 kind: "started",
14 });
15 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring");
16 liveness.recordTaskLiveness({
17 threadId,
18 taskId: "a1",
19 taskType: "subagent",
20 status: undefined,
21 kind: "started",
22 });
23 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working");
24 liveness.recordTaskLiveness({
25 threadId,
26 taskId: "a1",
27 taskType: "subagent",
28 status: "completed",
29 kind: "completed",
30 });
31 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring");
32 liveness.recordTaskLiveness({
33 threadId,
34 taskId: "m1",
35 taskType: "local_bash",
36 status: "completed",
37 kind: "completed",
38 });
39 expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
40 });
41
42 it("terminal rows without a taskType still clear monitor entries", () => {
43 const liveness = ThreadBackgroundLiveness.make();
44 const threadId = "t-live-2";
45 liveness.recordTaskLiveness({
46 threadId,
47 taskId: "m1",
48 taskType: "local_bash",
49 status: undefined,
50 kind: "started",
51 });
52 // Terminal tick arrives with no taskType (common on task.completed).
53 liveness.recordTaskLiveness({
54 threadId,
55 taskId: "m1",
56 taskType: undefined,
57 status: "completed",
58 kind: "completed",
59 });
60 expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
61 });
62
63 it("nested agents (agentId + agent taskType) still count toward liveness", () => {
64 const liveness = ThreadBackgroundLiveness.make();
65 const threadId = "t-live-nested";
66 liveness.recordTaskLiveness({
67 threadId,
68 taskId: "n1",
69 taskType: "local_agent",
70 status: undefined,
71 kind: "started",
72 agentId: "owner",
73 });
74 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working");
75 liveness.recordTaskLiveness({
76 threadId,
77 taskId: "n1",
78 taskType: "local_agent",
79 status: "completed",
80 kind: "completed",
81 agentId: "owner",
82 });
83 expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
84 });
85
86 it("untyped rows count as agents; idle is not live; agent-owned tasks are ignored", () => {
87 const liveness = ThreadBackgroundLiveness.make();
88 const threadId = "t-live-3";
89 liveness.recordTaskLiveness({
90 threadId,
91 taskId: "wf:1",
92 taskType: undefined,
93 status: "running",
94 kind: "progress",
95 });
96 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working");
97 liveness.recordTaskLiveness({
98 threadId,
99 taskId: "wf:1",
100 taskType: undefined,
101 status: "idle",
102 kind: "updated",
103 });
104 expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
105 liveness.recordTaskLiveness({
106 threadId,
107 taskId: "sh:1",
108 taskType: "local_bash",
109 status: undefined,
110 kind: "started",
111 agentId: "owner",
112 });
113 expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
114 });
115
116 it("reclassification moves a task between buckets instead of duplicating it", () => {
117 const liveness = ThreadBackgroundLiveness.make();
118 const threadId = "t-live-reclass";
119 // First seen without a taskType: counts as an agent.
120 liveness.recordTaskLiveness({
121 threadId,
122 taskId: "x1",
123 taskType: undefined,
124 status: "running",
125 kind: "started",
126 });
127 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("working");
128 // Later transition reveals it's a shell: downgrade to monitoring, not
129 // a stale duplicate pinning "working".
130 liveness.recordTaskLiveness({
131 threadId,
132 taskId: "x1",
133 taskType: "local_bash",
134 status: "running",
135 kind: "progress",
136 });
137 expect(liveness.getThreadBackgroundLiveness(threadId)).toBe("monitoring");
138 // Turning out to be inert or agent-owned drops the prior entry too.
139 liveness.recordTaskLiveness({
140 threadId,
141 taskId: "x1",
142 taskType: "local_bash",
143 status: "running",
144 kind: "progress",
145 agentId: "owner",
146 });
147 expect(liveness.getThreadBackgroundLiveness(threadId)).toBeNull();
148 });
149
150 it("plan tasks are inert; clear removes everything; instances are isolated", () => {
151 const a = ThreadBackgroundLiveness.make();
152 const b = ThreadBackgroundLiveness.make();
153 a.recordTaskLiveness({
154 threadId: "t",
155 taskId: "p1",
156 taskType: "plan",
157 status: undefined,
158 kind: "started",
159 });
160 expect(a.getThreadBackgroundLiveness("t")).toBeNull();
161 a.recordTaskLiveness({
162 threadId: "t",
163 taskId: "a1",
164 taskType: "local_workflow",
165 status: undefined,
166 kind: "started",
167 });
168 expect(a.getThreadBackgroundLiveness("t")).toBe("working");
169 expect(b.getThreadBackgroundLiveness("t")).toBeNull();
170 a.clearThreadLiveness("t");
171 expect(a.getThreadBackgroundLiveness("t")).toBeNull();
172 });
173});
apps/server/src/orchestration/ThreadBackgroundLiveness.ts created+160
...@@ -0,0 +1,160 @@
1/**
2 * ThreadBackgroundLivenessService - in-memory per-thread background liveness
3 * for the sidebar status pill.
4 *
5 * The turn can settle while native background work runs on (subagent fleets,
6 * workflow runs, Monitor watch loops); the shell previously showed nothing.
7 * Ingestion records task lifecycle transitions and the shell query reads the
8 * derived state at mapping time — no persistence, no migration. After a
9 * server restart the registry is empty until new task events arrive, which
10 * matches reality: orphaned background work is not live.
11 *
12 * "monitoring" is reserved for watch loops (monitor tasks and background
13 * shells) when they are the ONLY live work; any agent work presents as
14 * "working".
15 *
16 * @module ThreadBackgroundLivenessService
17 */
18import { INERT_TASK_TYPES, MONITOR_TASK_TYPES } from "@t3tools/contracts";
19import * as Context from "effect/Context";
20import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";
22
23export type ThreadBackgroundLiveness = "working" | "monitoring" | null;
24
25interface ThreadLivenessState {
26 readonly agents: Set<string>;
27 readonly monitors: Set<string>;
28}
29
30// Classification sets are the shared contracts copies (MONITOR_TASK_TYPES:
31// watch loops — monitor tasks plus background shells, which in practice are
32// PR babysitting/log tails since pacing sleeps complete inside the turn;
33// INERT_TASK_TYPES: plan-mode bookkeeping) so this registry, ingestion's
34// agentKind stamp, and the client fold can never drift apart.
35
36const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
37 "completed",
38 "failed",
39 "stopped",
40 "cancelled",
41 "interrupted",
42]);
43
44export class ThreadBackgroundLivenessService extends Context.Service<
45 ThreadBackgroundLivenessService,
46 {
47 /**
48 * Feed one task lifecycle transition. taskType may be absent on
49 * synthesized rows (workflow members, Codex children) — those count as
50 * agents. agentId marks a task launched from inside a subagent: its
51 * internal shells are covered by the owning agent's liveness, but a
52 * NESTED AGENT (agentId + agent-flavored taskType) still counts — it
53 * can outlive its parent and must keep the thread Working.
54 */
55 readonly recordTaskLiveness: (input: {
56 readonly threadId: string;
57 readonly taskId: string;
58 readonly taskType: string | undefined;
59 readonly status: string | undefined;
60 readonly kind: "started" | "progress" | "updated" | "completed";
61 readonly agentId?: string | undefined;
62 }) => void;
63
64 /** Session death orphans all of a thread's background work. */
65 readonly clearThreadLiveness: (threadId: string) => void;
66
67 /**
68 * Two-state vocabulary by design: any live agent work is "working";
69 * "monitoring" only when watch loops are the ONLY live work.
70 */
71 readonly getThreadBackgroundLiveness: (threadId: string) => ThreadBackgroundLiveness;
72 }
73>()("t3/orchestration/ThreadBackgroundLiveness/ThreadBackgroundLivenessService") {}
74
75export function make(): ThreadBackgroundLivenessService["Service"] {
76 const stateByThreadId = new Map<string, ThreadLivenessState>();
77
78 const stateFor = (threadId: string): ThreadLivenessState => {
79 const existing = stateByThreadId.get(threadId);
80 if (existing) {
81 return existing;
82 }
83 const created: ThreadLivenessState = { agents: new Set(), monitors: new Set() };
84 stateByThreadId.set(threadId, created);
85 return created;
86 };
87
88 // Classification is per-transition, not sticky: a task first seen without
89 // a taskType may later reveal itself as a shell, become inert, or turn out
90 // to be agent-owned. Every path drops any prior entry for the taskId so a
91 // stale bucket assignment can't pin the thread's status (review finding).
92 const drop = (threadId: string, taskId: string) => {
93 const state = stateByThreadId.get(threadId);
94 if (!state) {
95 return;
96 }
97 state.agents.delete(taskId);
98 state.monitors.delete(taskId);
99 if (state.agents.size === 0 && state.monitors.size === 0) {
100 stateByThreadId.delete(threadId);
101 }
102 };
103
104 return {
105 recordTaskLiveness: (input) => {
106 const taskType = input.taskType;
107 if (taskType !== undefined && INERT_TASK_TYPES.has(taskType)) {
108 drop(input.threadId, input.taskId);
109 return;
110 }
111 // A subagent's internal non-agent work (its own shells/monitors) is
112 // covered by the owning agent's liveness. Nested agents fall through:
113 // they can outlive their parent (review finding).
114 if (
115 input.agentId !== undefined &&
116 (taskType === undefined || MONITOR_TASK_TYPES.has(taskType))
117 ) {
118 drop(input.threadId, input.taskId);
119 return;
120 }
121
122 // Idle counts as not-live: a resting (resumable) Codex child isn't
123 // doing anything, and an all-idle fleet must not pin Working.
124 const terminal =
125 input.kind === "completed" ||
126 input.status === "idle" ||
127 (input.status !== undefined && TERMINAL_STATUSES.has(input.status));
128 if (terminal) {
129 drop(input.threadId, input.taskId);
130 return;
131 }
132
133 drop(input.threadId, input.taskId);
134 const state = stateFor(input.threadId);
135 const bucket =
136 taskType !== undefined && MONITOR_TASK_TYPES.has(taskType) ? state.monitors : state.agents;
137 bucket.add(input.taskId);
138 },
139
140 clearThreadLiveness: (threadId) => {
141 stateByThreadId.delete(threadId);
142 },
143
144 getThreadBackgroundLiveness: (threadId) => {
145 const state = stateByThreadId.get(threadId);
146 if (!state) {
147 return null;
148 }
149 if (state.agents.size > 0) {
150 return "working";
151 }
152 if (state.monitors.size > 0) {
153 return "monitoring";
154 }
155 return null;
156 },
157 };
158}
159
160export const layer = Layer.effect(ThreadBackgroundLivenessService, Effect.sync(make));
apps/server/src/orchestration/runtimeLayer.ts+5-1
...@@ -5,6 +5,7 @@ import { OrchestrationEventStoreLive } from "../persistence/Layers/Orchestration...@@ -5,6 +5,7 @@ import { OrchestrationEventStoreLive } from "../persistence/Layers/Orchestration
5import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";5import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
6import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";6import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
7import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";7import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
8import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
89
9export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(10export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
10 OrchestrationEventStoreLive,11 OrchestrationEventStoreLive,
...@@ -19,7 +20,10 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(...@@ -19,7 +20,10 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
19 OrchestrationProjectionSnapshotQueryLive,20 OrchestrationProjectionSnapshotQueryLive,
20 OrchestrationEventInfrastructureLayerLive,21 OrchestrationEventInfrastructureLayerLive,
21 OrchestrationProjectionPipelineLayerLive,22 OrchestrationProjectionPipelineLayerLive,
22);23 // Shared background-liveness registry: written by runtime ingestion,
24 // read by the snapshot query. provideMerge feeds the same instance to
25 // the snapshot query here and re-exports it for runtime ingestion.
26).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
2327
24export const OrchestrationLayerLive = Layer.mergeAll(28export const OrchestrationLayerLive = Layer.mergeAll(
25 OrchestrationInfrastructureLayerLive,29 OrchestrationInfrastructureLayerLive,
apps/server/src/orchestration/workflowScriptQuery.test.ts created+75
...@@ -0,0 +1,75 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { it as effectIt } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import { afterAll, assert, describe } from "vite-plus/test";
8import { readWorkflowScript } from "./workflowScriptQuery.ts";
9
10const root = NodePath.join(NodeOS.homedir(), ".claude", "projects", "__wf_script_test__");
11NodeFS.mkdirSync(root, { recursive: true });
12const scriptPath = NodePath.join(root, "run.js");
13NodeFS.writeFileSync(scriptPath, "export const meta = {};\n");
14const outside = NodePath.join(NodeOS.tmpdir(), "wf-outside.js");
15NodeFS.writeFileSync(outside, "evil\n");
16const link = NodePath.join(root, "sneaky.js");
17try {
18 NodeFS.symlinkSync(outside, link);
19} catch (error) {
20 // Tolerate only "already exists" from a prior run — any other failure
21 // (EPERM etc.) must fail setup, or the escape test below would pass
22 // vacuously on "not-found" without testing containment.
23 if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
24 throw error;
25 }
26}
27if (!NodeFS.lstatSync(link).isSymbolicLink()) {
28 throw new Error("test setup: sneaky.js must be a symlink");
29}
30
31afterAll(() => {
32 NodeFS.rmSync(root, { recursive: true, force: true });
33 NodeFS.rmSync(outside, { force: true });
34});
35
36describe("readWorkflowScript containment", () => {
37 effectIt.effect("serves a real script under the projects root", () =>
38 Effect.gen(function* () {
39 const result = yield* readWorkflowScript({ scriptPath });
40 assert.include(result.contents, "export const meta");
41 assert.equal(result.truncated, false);
42 }),
43 );
44
45 effectIt.effect("rejects relative and non-js paths", () =>
46 Effect.gen(function* () {
47 const relative = yield* Effect.exit(readWorkflowScript({ scriptPath: "run.js" }));
48 assert.equal(relative._tag, "Failure");
49 const nonJs = yield* Effect.exit(
50 readWorkflowScript({ scriptPath: scriptPath.replace(".js", ".ts") }),
51 );
52 assert.equal(nonJs._tag, "Failure");
53 }),
54 );
55
56 effectIt.effect("rejects paths outside the root and symlink escapes", () =>
57 Effect.gen(function* () {
58 const escaped = yield* Effect.exit(readWorkflowScript({ scriptPath: outside }));
59 assert.equal(escaped._tag, "Failure");
60 // A symlink INSIDE the root pointing outside must fail specifically on
61 // realpath re-containment — a "not-found" would mean the link was
62 // never exercised and the assertion proves nothing.
63 const sneaky = yield* Effect.exit(
64 readWorkflowScript({ scriptPath: link }).pipe(
65 Effect.flip,
66 Effect.map((error) => error.reason),
67 ),
68 );
69 assert.equal(sneaky._tag, "Success");
70 if (sneaky._tag === "Success") {
71 assert.equal(sneaky.value, "outside-root");
72 }
73 }),
74 );
75});
apps/server/src/orchestration/workflowScriptQuery.ts created+124
...@@ -0,0 +1,124 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * Read-only access to persisted workflow scripts for the Agents surface's
4 * "{} script" affordance.
5 *
6 * Containment rules (lifted from the reviewed #3650 inspection service):
7 * - the resolved realpath must live under ~/.claude/projects (where the
8 * Claude harness persists workflow scripts) — realpath re-containment
9 * defeats symlink escapes, including a symlinked leaf file;
10 * - only .js leaf files are served;
11 * - reads are size-capped rather than failed, with a truncation marker.
12 *
13 * The client-supplied path is a hint from the workflow's runHandles; it is
14 * never trusted beyond these checks.
15 */
16import * as NodeFSP from "node:fs/promises";
17import * as NodeOS from "node:os";
18import * as NodePath from "node:path";
19
20import { OrchestrationGetWorkflowScriptError } from "@t3tools/contracts";
21import * as Effect from "effect/Effect";
22
23const SCRIPT_BYTE_CAP = 256 * 1024;
24
25function scriptsRoot(): string {
26 return NodePath.join(NodeOS.homedir(), ".claude", "projects");
27}
28
29export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")(function* (input: {
30 readonly scriptPath: string;
31}) {
32 const requested = input.scriptPath;
33
34 if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") {
35 return yield* Effect.fail(
36 new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }),
37 );
38 }
39
40 const root = yield* Effect.tryPromise({
41 try: () => NodeFSP.realpath(scriptsRoot()),
42 catch: (cause) =>
43 new OrchestrationGetWorkflowScriptError({
44 reason: "root-unavailable",
45 scriptPath: requested,
46 cause,
47 }),
48 });
49
50 // Realpath the FILE itself (not just its directory): a symlink named
51 // like a script inside a contained directory must not escape.
52 const resolved = yield* Effect.tryPromise({
53 try: () => NodeFSP.realpath(requested),
54 catch: (cause) =>
55 new OrchestrationGetWorkflowScriptError({
56 reason: "not-found",
57 scriptPath: requested,
58 cause,
59 }),
60 });
61
62 if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) {
63 return yield* Effect.fail(
64 new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }),
65 );
66 }
67 if (NodePath.extname(resolved) !== ".js") {
68 return yield* Effect.fail(
69 new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }),
70 );
71 }
72
73 // TOCTOU-safe read (review finding): open FIRST, then verify what was
74 // actually opened via the file descriptor. Re-checking the path after
75 // open would race against a swap; fstat on the handle cannot. The two
76 // containment checks fail with their own tagged reasons (not manufactured
77 // Errors folded into read-failed); "read-failed" is reserved for genuine
78 // platform failures with the real cause attached.
79 const read = yield* Effect.tryPromise({
80 try: async () => {
81 const handle = await NodeFSP.open(resolved, "r");
82 try {
83 const stat = await handle.stat();
84 if (!stat.isFile()) {
85 return { failure: "not-regular-file" as const };
86 }
87 // The opened inode must be the same one realpath resolved to: a
88 // process swapping the path between realpath and open changes the
89 // inode, which this comparison catches.
90 const pathStat = await NodeFSP.lstat(resolved);
91 if (stat.ino !== pathStat.ino || stat.dev !== pathStat.dev) {
92 return { failure: "changed-during-read" as const };
93 }
94 const truncated = stat.size > SCRIPT_BYTE_CAP;
95 const buffer = Buffer.alloc(Math.min(stat.size, SCRIPT_BYTE_CAP));
96 const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
97 return {
98 contents: buffer.subarray(0, bytesRead).toString("utf8"),
99 truncated,
100 };
101 } finally {
102 await handle.close();
103 }
104 },
105 catch: (cause) =>
106 new OrchestrationGetWorkflowScriptError({
107 reason: "read-failed",
108 scriptPath: resolved,
109 cause,
110 }),
111 });
112 if ("failure" in read) {
113 return yield* new OrchestrationGetWorkflowScriptError({
114 reason: read.failure,
115 scriptPath: resolved,
116 });
117 }
118
119 return {
120 scriptPath: resolved,
121 contents: read.contents,
122 truncated: read.truncated,
123 };
124});
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+242
...@@ -55,6 +55,7 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {...@@ -55,6 +55,7 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
55 private failure: unknown | undefined;55 private failure: unknown | undefined;
5656
57 public readonly interruptCalls: Array<void> = [];57 public readonly interruptCalls: Array<void> = [];
58 public readonly stopTaskCalls: Array<string> = [];
58 public readonly setModelCalls: Array<string | undefined> = [];59 public readonly setModelCalls: Array<string | undefined> = [];
59 public readonly setPermissionModeCalls: Array<string> = [];60 public readonly setPermissionModeCalls: Array<string> = [];
60 public readonly setMaxThinkingTokensCalls: Array<number | null> = [];61 public readonly setMaxThinkingTokensCalls: Array<number | null> = [];
...@@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {...@@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
98 this.interruptCalls.push(undefined);99 this.interruptCalls.push(undefined);
99 };100 };
100101
102 readonly stopTask = async (taskId: string): Promise<void> => {
103 this.stopTaskCalls.push(taskId);
104 };
105
101 readonly setModel = async (model?: string): Promise<void> => {106 readonly setModel = async (model?: string): Promise<void> => {
102 this.setModelCalls.push(model);107 this.setModelCalls.push(model);
103 };108 };
...@@ -1445,6 +1450,243 @@ describe("ClaudeAdapterLive", () => {...@@ -1445,6 +1450,243 @@ describe("ClaudeAdapterLive", () => {
1445 );1450 );
1446 });1451 });
14471452
1453 it.effect("interruptTurn stops every live task before interrupting the turn", () => {
1454 const harness = makeHarness();
1455 return Effect.gen(function* () {
1456 const adapter = yield* ClaudeAdapter;
1457
1458 // Wait for the three task.* runtime events to prove the lifecycle
1459 // handlers processed the emissions (no wall-clock sleeps under the
1460 // test clock).
1461 const taskEventsFiber = yield* adapter.streamEvents.pipe(
1462 Stream.filter((event) => event.type.startsWith("task.")),
1463 Stream.take(3),
1464 Stream.runCollect,
1465 Effect.forkChild,
1466 );
1467
1468 const session = yield* adapter.startSession({
1469 threadId: THREAD_ID,
1470 provider: ProviderDriverKind.make("claudeAgent"),
1471 runtimeMode: "full-access",
1472 });
1473 yield* adapter.sendTurn({
1474 threadId: session.threadId,
1475 input: "spawn agents",
1476 attachments: [],
1477 });
1478
1479 harness.query.emit({
1480 type: "system",
1481 subtype: "task_started",
1482 task_id: "task-live",
1483 description: "Agent A",
1484 task_type: "local_agent",
1485 uuid: "task-live-uuid",
1486 session_id: "sdk-session",
1487 } as unknown as SDKMessage);
1488 harness.query.emit({
1489 type: "system",
1490 subtype: "task_started",
1491 task_id: "task-settled",
1492 description: "Agent B",
1493 task_type: "local_agent",
1494 uuid: "task-settled-uuid",
1495 session_id: "sdk-session",
1496 } as unknown as SDKMessage);
1497 harness.query.emit({
1498 type: "system",
1499 subtype: "task_notification",
1500 task_id: "task-settled",
1501 status: "completed",
1502 output_file: "/tmp/task-settled.jsonl",
1503 summary: "done",
1504 uuid: "task-settled-done-uuid",
1505 session_id: "sdk-session",
1506 } as unknown as SDKMessage);
1507
1508 yield* Fiber.join(taskEventsFiber);
1509
1510 yield* adapter.interruptTurn(session.threadId);
1511
1512 // Only the still-live task is stopped; interrupt always fires after.
1513 assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]);
1514 assert.equal(harness.query.interruptCalls.length, 1);
1515 }).pipe(
1516 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1517 Effect.provide(harness.layer),
1518 );
1519 });
1520
1521 it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => {
1522 const harness = makeHarness();
1523 return Effect.gen(function* () {
1524 const adapter = yield* ClaudeAdapter;
1525
1526 // Collect task.progress until member-0's tick-3 emission lands, then
1527 // evaluate member emissions.
1528 const progressFiber = yield* adapter.streamEvents.pipe(
1529 Stream.filter((event) => event.type === "task.progress"),
1530 Stream.takeUntil(
1531 // Sentinel: member-0's tick-3 emission (tokens 20) — members are
1532 // emitted after the coordinator row within a tick.
1533 (event) =>
1534 (event.payload as { taskId?: string }).taskId === "wf-coalesce:wf:0" &&
1535 (event.payload as { typedUsage?: { totalTokens?: number } }).typedUsage?.totalTokens ===
1536 20,
1537 ),
1538 Stream.runCollect,
1539 Effect.forkChild,
1540 );
1541
1542 const session = yield* adapter.startSession({
1543 threadId: THREAD_ID,
1544 provider: ProviderDriverKind.make("claudeAgent"),
1545 runtimeMode: "full-access",
1546 });
1547 yield* adapter.sendTurn({
1548 threadId: session.threadId,
1549 input: "run workflow",
1550 attachments: [],
1551 });
1552
1553 const memberSnapshot = (tokens: number) => [
1554 { type: "workflow_phase", index: 0, title: "Work" },
1555 {
1556 type: "workflow_agent",
1557 index: 0,
1558 state: "running",
1559 label: "member-0",
1560 phaseIndex: 0,
1561 tokens,
1562 },
1563 {
1564 type: "workflow_agent",
1565 index: 1,
1566 state: "running",
1567 label: "member-1",
1568 phaseIndex: 0,
1569 tokens: 50,
1570 },
1571 ];
1572 const tick = (usageTotal: number, snapshot: ReturnType<typeof memberSnapshot>) =>
1573 harness.query.emit({
1574 type: "system",
1575 subtype: "task_progress",
1576 task_id: "wf-coalesce",
1577 description: "Coalescing workflow",
1578 usage: { total_tokens: usageTotal, tool_uses: 1, duration_ms: 10 },
1579 workflow_progress: snapshot,
1580 uuid: `wf-tick-${usageTotal}`,
1581 session_id: "sdk-session",
1582 } as unknown as SDKMessage);
1583
1584 // Tick 1: both members are new -> 2 member events.
1585 tick(100, memberSnapshot(10));
1586 // Tick 2: IDENTICAL member snapshot -> 0 member events (coordinator
1587 // usage changed, but members did not).
1588 tick(200, memberSnapshot(10));
1589 // Tick 3: member-0's tokens advanced -> exactly 1 member event.
1590 tick(300, memberSnapshot(20));
1591
1592 const progressEvents = Array.from(yield* Fiber.join(progressFiber));
1593 const byMember = new Map<string, number>();
1594 for (const event of progressEvents) {
1595 const taskId = (event.payload as { taskId: string }).taskId;
1596 if (!taskId.includes(":wf:")) continue;
1597 byMember.set(taskId, (byMember.get(taskId) ?? 0) + 1);
1598 }
1599 // member-0: tick 1 + tick 3. member-1: tick 1 only (tick 2 identical,
1600 // tick 3 unchanged).
1601 assert.equal(byMember.get("wf-coalesce:wf:0"), 2);
1602 assert.equal(byMember.get("wf-coalesce:wf:1"), 1);
1603 }).pipe(
1604 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1605 Effect.provide(harness.layer),
1606 );
1607 });
1608
1609 it.effect("task.started carries model/effort; subagent snapshots refine the model", () => {
1610 const harness = makeHarness();
1611 return Effect.gen(function* () {
1612 const adapter = yield* ClaudeAdapter;
1613
1614 const taskEventsFiber = yield* adapter.streamEvents.pipe(
1615 Stream.filter((event) => event.type.startsWith("task.")),
1616 Stream.take(2),
1617 Stream.runCollect,
1618 Effect.forkChild,
1619 );
1620
1621 const session = yield* adapter.startSession({
1622 threadId: THREAD_ID,
1623 provider: ProviderDriverKind.make("claudeAgent"),
1624 modelSelection: createModelSelection(
1625 ProviderInstanceId.make("claudeAgent"),
1626 "claude-opus-4-6",
1627 [{ id: "effort", value: "max" }],
1628 ),
1629 runtimeMode: "full-access",
1630 });
1631 yield* adapter.sendTurn({
1632 threadId: session.threadId,
1633 input: "spawn an agent",
1634 attachments: [],
1635 });
1636
1637 // No explicit model/effort on the launch input: the task inherits the
1638 // session's selection.
1639 harness.query.emit({
1640 type: "system",
1641 subtype: "task_started",
1642 task_id: "task-model",
1643 description: "Agent M",
1644 task_type: "local_agent",
1645 tool_use_id: "toolu_agent_m",
1646 uuid: "task-model-uuid",
1647 session_id: "sdk-session",
1648 } as unknown as SDKMessage);
1649 // The subagent's assistant snapshot carries the authoritative API
1650 // model id, which refines the linkage on later rows.
1651 harness.query.emit({
1652 type: "assistant",
1653 parent_tool_use_id: "toolu_agent_m",
1654 message: {
1655 model: "claude-sonnet-5[1m]",
1656 content: [],
1657 },
1658 uuid: "subagent-snapshot-uuid",
1659 session_id: "sdk-session",
1660 } as unknown as SDKMessage);
1661 harness.query.emit({
1662 type: "system",
1663 subtype: "task_progress",
1664 task_id: "task-model",
1665 description: "Agent M",
1666 usage: { total_tokens: 100, tool_uses: 1, duration_ms: 10 },
1667 uuid: "task-model-progress-uuid",
1668 session_id: "sdk-session",
1669 } as unknown as SDKMessage);
1670
1671 const taskEvents = Array.from(yield* Fiber.join(taskEventsFiber));
1672 const started = taskEvents[0];
1673 assert.equal(started?.type, "task.started");
1674 if (started?.type === "task.started") {
1675 assert.equal(started.payload.model, "claude-opus-4-6");
1676 assert.equal(started.payload.effort, "max");
1677 }
1678 const progress = taskEvents[1];
1679 assert.equal(progress?.type, "task.progress");
1680 if (progress?.type === "task.progress") {
1681 assert.equal(progress.payload.model, "claude-sonnet-5[1m]");
1682 assert.equal(progress.payload.effort, "max");
1683 }
1684 }).pipe(
1685 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1686 Effect.provide(harness.layer),
1687 );
1688 });
1689
1448 it.effect("closes the session when the Claude stream aborts after a turn starts", () => {1690 it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
1449 const harness = makeHarness();1691 const harness = makeHarness();
1450 return Effect.gen(function* () {1692 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+586-12
...@@ -42,6 +42,10 @@ import {...@@ -42,6 +42,10 @@ import {
42 RuntimeItemId,42 RuntimeItemId,
43 RuntimeRequestId,43 RuntimeRequestId,
44 RuntimeTaskId,44 RuntimeTaskId,
45 type RuntimeTaskStatus,
46 type RuntimeTaskUsage,
47 type TaskAgentLinkage,
48 type TaskRunHandles,
45 ThreadId,49 ThreadId,
46 TurnId,50 TurnId,
47 type UserInputQuestion,51 type UserInputQuestion,
...@@ -169,6 +173,9 @@ interface ToolInFlight {...@@ -169,6 +173,9 @@ interface ToolInFlight {
169 readonly input: Record<string, unknown>;173 readonly input: Record<string, unknown>;
170 readonly partialInputJson: string;174 readonly partialInputJson: string;
171 readonly lastEmittedInputFingerprint?: string;175 readonly lastEmittedInputFingerprint?: string;
176 /** Owning agent when this tool ran inside a subagent (see attribution note). */
177 readonly agentId?: string;
178 readonly parentToolUseId?: string;
172}179}
173180
174interface ClaudeTaskState {181interface ClaudeTaskState {
...@@ -178,6 +185,28 @@ interface ClaudeTaskState {...@@ -178,6 +185,28 @@ interface ClaudeTaskState {
178 readonly blockedBy: Set<string>;185 readonly blockedBy: Set<string>;
179}186}
180187
188/**
189 * Agent identity captured from task_started and repeated on every subsequent
190 * task.* payload, so client folds can reconstruct an agent even when its
191 * start row aged out of activity retention.
192 */
193interface ClaudeTaskAgentState {
194 readonly taskId: string;
195 toolUseId: string | undefined;
196 description: string | undefined;
197 subagentType: string | undefined;
198 taskType: string | undefined;
199 workflowName: string | undefined;
200 skipTranscript: boolean;
201 runHandles: TaskRunHandles | undefined;
202 /** Set when this task was launched from inside a subagent. */
203 owningAgentId: string | undefined;
204 /** Seeded from the launching tool's input; refined by the subagent's own
205 * assistant snapshots (authoritative API model). */
206 model: string | undefined;
207 effort: string | undefined;
208}
209
181interface ClaudeSessionContext {210interface ClaudeSessionContext {
182 session: ProviderSession;211 session: ProviderSession;
183 readonly promptQueue: Queue.Queue<PromptQueueItem>;212 readonly promptQueue: Queue.Queue<PromptQueueItem>;
...@@ -186,6 +215,9 @@ interface ClaudeSessionContext {...@@ -186,6 +215,9 @@ interface ClaudeSessionContext {
186 readonly startedAt: string;215 readonly startedAt: string;
187 readonly basePermissionMode: PermissionMode | undefined;216 readonly basePermissionMode: PermissionMode | undefined;
188 currentApiModelId: string | undefined;217 currentApiModelId: string | undefined;
218 /** Effective effort for the session's turns; subagents without an explicit
219 * effort override inherit this. */
220 currentEffort: string | undefined;
189 resumeSessionId: string | undefined;221 resumeSessionId: string | undefined;
190 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;222 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
191 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;223 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
...@@ -195,6 +227,17 @@ interface ClaudeSessionContext {...@@ -195,6 +227,17 @@ interface ClaudeSessionContext {
195 }>;227 }>;
196 readonly inFlightTools: Map<number, ToolInFlight>;228 readonly inFlightTools: Map<number, ToolInFlight>;
197 readonly claudeTasks: Map<string, ClaudeTaskState>;229 readonly claudeTasks: Map<string, ClaudeTaskState>;
230 readonly taskAgents: Map<string, ClaudeTaskAgentState>;
231 /**
232 * Last emitted workflow-member fingerprint per member slot. A coordinator
233 * task_progress repeats the FULL member array every tick; without a
234 * material-transition filter one provider tick fans out into up to 100
235 * runtime events (event-log writes, queue pressure, client reducer work)
236 * even when nothing changed for most members.
237 */
238 readonly workflowMemberFingerprints: Map<string, string>;
239 /** Task ids that have started and not yet reached a terminal state. */
240 readonly liveTaskIds: Set<string>;
198 turnState: ClaudeTurnState | undefined;241 turnState: ClaudeTurnState | undefined;
199 lastKnownContextWindow: number | undefined;242 lastKnownContextWindow: number | undefined;
200 lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;243 lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;
...@@ -206,6 +249,8 @@ interface ClaudeSessionContext {...@@ -206,6 +249,8 @@ interface ClaudeSessionContext {
206249
207interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {250interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
208 readonly interrupt: () => Promise<void>;251 readonly interrupt: () => Promise<void>;
252 /** SDK Query.stopTask — present on real queries; optional for test doubles. */
253 readonly stopTask?: (taskId: string) => Promise<void>;
209 readonly setModel: (model?: string) => Promise<void>;254 readonly setModel: (model?: string) => Promise<void>;
210 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;255 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
211 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;256 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
...@@ -826,6 +871,225 @@ function planStepsFromClaudeTasks(tasks: Map<string, ClaudeTaskState>): PlanStep...@@ -826,6 +871,225 @@ function planStepsFromClaudeTasks(tasks: Map<string, ClaudeTaskState>): PlanStep
826 });871 });
827}872}
828873
874/** Only http/https survive; anything else (javascript:, file:, …) is dropped. */
875function sanitizeSessionUrl(value: unknown): string | undefined {
876 if (typeof value !== "string") {
877 return undefined;
878 }
879 const trimmed = value.trim();
880 if (!/^https?:\/\//i.test(trimmed)) {
881 return undefined;
882 }
883 return trimmed;
884}
885
886function nonNegativeInt(value: unknown): number | undefined {
887 return typeof value === "number" && Number.isFinite(value) && value >= 0
888 ? Math.floor(value)
889 : undefined;
890}
891
892function trimmedString(value: unknown): string | undefined {
893 if (typeof value !== "string") {
894 return undefined;
895 }
896 const trimmed = value.trim();
897 return trimmed.length > 0 ? trimmed : undefined;
898}
899
900/**
901 * SDK task usage ({total_tokens, tool_uses, duration_ms}, sometimes with
902 * input/output/cache breakdowns) → the typed contract shape. Unknown or
903 * malformed input yields undefined rather than a partial guess.
904 */
905function normalizeTaskUsage(usage: unknown): RuntimeTaskUsage | undefined {
906 if (typeof usage !== "object" || usage === null) {
907 return undefined;
908 }
909 const record = usage as Record<string, unknown>;
910 const totalTokens = nonNegativeInt(record.total_tokens);
911 if (totalTokens === undefined) {
912 return undefined;
913 }
914 const inputTokens = nonNegativeInt(record.input_tokens);
915 const cachedInputTokens = nonNegativeInt(record.cache_read_input_tokens);
916 const outputTokens = nonNegativeInt(record.output_tokens);
917 const toolUses = nonNegativeInt(record.tool_uses);
918 const durationMs = nonNegativeInt(record.duration_ms);
919 return {
920 totalTokens,
921 ...(inputTokens !== undefined ? { inputTokens } : {}),
922 ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
923 ...(outputTokens !== undefined ? { outputTokens } : {}),
924 ...(toolUses !== undefined ? { toolUses } : {}),
925 ...(durationMs !== undefined ? { durationMs } : {}),
926 };
927}
928
929/** SDK task_updated patch status → the shared wire vocabulary. */
930const CLAUDE_TASK_PATCH_STATUS: Record<string, RuntimeTaskStatus> = {
931 pending: "pending",
932 running: "running",
933 completed: "completed",
934 failed: "failed",
935 killed: "cancelled",
936 paused: "idle",
937};
938
939/**
940 * Resolves a stream message's parent_tool_use_id to the owning agent's
941 * taskId. The Task tool's tool_use_id is remembered on task_started; any
942 * subagent-forwarded block carries that id as its parent. Returns undefined
943 * for parent-conversation traffic.
944 */
945function agentIdForParentToolUse(
946 agents: Map<string, ClaudeTaskAgentState>,
947 parentToolUseId: string | null | undefined,
948): string | undefined {
949 if (parentToolUseId === null || parentToolUseId === undefined) {
950 return undefined;
951 }
952 for (const agent of agents.values()) {
953 if (agent.toolUseId === parentToolUseId) {
954 return agent.taskId;
955 }
956 }
957 return undefined;
958}
959
960/**
961 * Linkage bundle repeated on every task.* payload for `taskId`. Reads the
962 * remembered identity (from task_started) so progress/terminal rows are
963 * self-describing even when the start row ages out of activity retention.
964 */
965function taskLinkageFor(
966 agents: Map<string, ClaudeTaskAgentState>,
967 taskId: string,
968): TaskAgentLinkage {
969 const agent = agents.get(taskId);
970 if (!agent) {
971 return {};
972 }
973 return {
974 ...(agent.taskType ? { taskType: agent.taskType } : {}),
975 ...(agent.owningAgentId ? { agentId: agent.owningAgentId } : {}),
976 ...(agent.description ? { title: agent.description } : {}),
977 ...(agent.subagentType ? { role: agent.subagentType } : {}),
978 ...(agent.model ? { model: agent.model } : {}),
979 ...(agent.effort ? { effort: agent.effort } : {}),
980 ...(agent.toolUseId ? { toolUseId: agent.toolUseId } : {}),
981 ...(agent.workflowName ? { workflowName: agent.workflowName } : {}),
982 ...(agent.runHandles ? { runHandles: agent.runHandles } : {}),
983 };
984}
985
986const WORKFLOW_PHASE_CAP = 64;
987const WORKFLOW_AGENT_CAP = 100;
988
989interface ClaudeWorkflowAgentEntry {
990 readonly index: number;
991 readonly state: string;
992 readonly label: string | undefined;
993 readonly phaseIndex: number | undefined;
994 readonly phaseTitle: string | undefined;
995 readonly model: string | undefined;
996 readonly attempt: number | undefined;
997 readonly lastToolName: string | undefined;
998 readonly startedAt: string | undefined;
999 readonly error: string | undefined;
1000 readonly tokens: number | undefined;
1001 readonly toolCalls: number | undefined;
1002}
1003
1004interface ClaudeWorkflowProgress {
1005 readonly phases: ReadonlyArray<{ index: number; title: string }>;
1006 readonly agents: ReadonlyArray<ClaudeWorkflowAgentEntry>;
1007}
1008
1009/**
1010 * Defensive parse of the SDK's undeclared-but-real workflow_progress array on
1011 * task_progress messages (wire-confirmed; absent from sdk.d.ts). Unknown
1012 * shapes are skipped per-entry; phases and agents dedupe by index before
1013 * caps; a vanished field never throws. If the array disappears upstream the
1014 * caller keeps the coordinator row and plain task lifecycle.
1015 */
1016function parseWorkflowProgress(value: unknown): ClaudeWorkflowProgress | undefined {
1017 if (!Array.isArray(value) || value.length === 0) {
1018 return undefined;
1019 }
1020 const phasesByIndex = new Map<number, string>();
1021 const agentsByIndex = new Map<number, ClaudeWorkflowAgentEntry>();
1022 for (const entry of value) {
1023 if (typeof entry !== "object" || entry === null) {
1024 continue;
1025 }
1026 const record = entry as Record<string, unknown>;
1027 const entryType = trimmedString(record.type);
1028 if (entryType === "workflow_phase") {
1029 const index = nonNegativeInt(record.index);
1030 const title = trimmedString(record.title);
1031 if (index !== undefined && title && !phasesByIndex.has(index)) {
1032 phasesByIndex.set(index, title);
1033 }
1034 continue;
1035 }
1036 if (entryType !== "workflow_agent") {
1037 continue;
1038 }
1039 const index = nonNegativeInt(record.index);
1040 const state = trimmedString(record.state);
1041 if (index === undefined || !state || agentsByIndex.has(index)) {
1042 continue;
1043 }
1044 agentsByIndex.set(index, {
1045 index,
1046 state,
1047 label: trimmedString(record.label),
1048 phaseIndex: nonNegativeInt(record.phaseIndex),
1049 phaseTitle: trimmedString(record.phaseTitle),
1050 model: trimmedString(record.model),
1051 attempt: nonNegativeInt(record.attempt),
1052 lastToolName: trimmedString(record.lastToolName),
1053 startedAt: trimmedString(record.startedAt),
1054 error: trimmedString(record.error),
1055 tokens: nonNegativeInt(record.tokens),
1056 toolCalls: nonNegativeInt(record.toolCalls),
1057 });
1058 }
1059 if (phasesByIndex.size === 0 && agentsByIndex.size === 0) {
1060 return undefined;
1061 }
1062 const phases = Array.from(phasesByIndex.entries())
1063 .map(([index, title]) => ({ index, title }))
1064 .toSorted((a, b) => a.index - b.index)
1065 .slice(0, WORKFLOW_PHASE_CAP);
1066 const agents = Array.from(agentsByIndex.values())
1067 .toSorted((a, b) => a.index - b.index)
1068 .slice(0, WORKFLOW_AGENT_CAP);
1069 return { phases, agents };
1070}
1071
1072/**
1073 * Workflow member states from workflow_progress → shared task status.
1074 * Unknown states read running after startedAt, pending before it.
1075 */
1076function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus {
1077 switch (entry.state) {
1078 case "queued":
1079 case "pending":
1080 return "pending";
1081 case "start":
1082 case "running":
1083 return entry.startedAt === undefined ? "pending" : "running";
1084 case "done":
1085 return "completed";
1086 case "error":
1087 return "failed";
1088 default:
1089 return entry.startedAt === undefined ? "pending" : "running";
1090 }
1091}
1092
829function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {1093function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
830 const commandValue = input.command ?? input.cmd;1094 const commandValue = input.command ?? input.cmd;
831 const command = typeof commandValue === "string" ? commandValue : undefined;1095 const command = typeof commandValue === "string" ? commandValue : undefined;
...@@ -833,17 +1097,17 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>):...@@ -833,17 +1097,17 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>):
833 return `${toolName}: ${command.trim().slice(0, 400)}`;1097 return `${toolName}: ${command.trim().slice(0, 400)}`;
834 }1098 }
8351099
836 // For agent/subagent tools, prefer human-readable description or prompt over raw JSON1100 // For agent/subagent tools, prefer the human-readable description or prompt
1101 // over raw JSON. The structured subagent_type is carried separately on the
1102 // task.* payloads (role) — the label is display-only.
837 const itemType = classifyToolItemType(toolName);1103 const itemType = classifyToolItemType(toolName);
838 if (itemType === "collab_agent_tool_call") {1104 if (itemType === "collab_agent_tool_call") {
839 const description =1105 const description =
840 typeof input.description === "string" ? input.description.trim() : undefined;1106 typeof input.description === "string" ? input.description.trim() : undefined;
841 const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined;1107 const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined;
842 const subagentType =
843 typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined;
844 const label = description || (prompt ? prompt.slice(0, 200) : undefined);1108 const label = description || (prompt ? prompt.slice(0, 200) : undefined);
845 if (label) {1109 if (label) {
846 return subagentType ? `${subagentType}: ${label}` : label;1110 return label;
847 }1111 }
848 }1112 }
8491113
...@@ -2078,6 +2342,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2078,6 +2342,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
20782342
2079 const { event } = message;2343 const { event } = message;
20802344
2345 // Subagent-owned stream traffic (parent_tool_use_id set) must not write
2346 // into the parent transcript: with forwardSubagentText off the SDK still
2347 // forwards subagent tool_use/tool_result blocks and their wrapping
2348 // text/thinking deltas, and emitting them interleaved N subagents'
2349 // narration into the chat (live-test finding). Their results reach the
2350 // UI via the task.* lifecycle; their tool blocks are attributed and
2351 // re-homed by the quiet-timeline filter.
2352 const streamParentToolUseId = (message as { parent_tool_use_id?: string | null })
2353 .parent_tool_use_id;
2354 if (streamParentToolUseId !== null && streamParentToolUseId !== undefined) {
2355 // Drop only the subagent's narration (text/thinking); tool_use blocks
2356 // and their input_json_delta frames must flow so attributed tool items
2357 // keep their inputs (review finding: dropping deltas emptied inputs).
2358 const dropStart =
2359 event.type === "content_block_start" &&
2360 event.content_block.type !== "tool_use" &&
2361 event.content_block.type !== "server_tool_use" &&
2362 event.content_block.type !== "mcp_tool_use";
2363 const dropDelta =
2364 event.type === "content_block_delta" &&
2365 (event.delta.type === "text_delta" || event.delta.type === "thinking_delta");
2366 if (dropStart || dropDelta) {
2367 return;
2368 }
2369 }
2370
2081 if (event.type === "message_delta") {2371 if (event.type === "message_delta") {
2082 if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {2372 if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {
2083 return;2373 return;
...@@ -2205,6 +2495,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2205,6 +2495,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2205 status: "inProgress",2495 status: "inProgress",
2206 title: nextTool.title,2496 title: nextTool.title,
2207 ...(nextTool.detail ? { detail: nextTool.detail } : {}),2497 ...(nextTool.detail ? { detail: nextTool.detail } : {}),
2498 ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}),
2499 ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}),
2208 data: {2500 data: {
2209 toolName: nextTool.toolName,2501 toolName: nextTool.toolName,
2210 input: nextTool.input,2502 input: nextTool.input,
...@@ -2274,6 +2566,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2274,6 +2566,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2274 const inputFingerprint =2566 const inputFingerprint =
2275 Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined;2567 Object.keys(toolInput).length > 0 ? toolInputFingerprint(toolInput) : undefined;
22762568
2569 // Attribute tools that ran inside a subagent to their owning agent so
2570 // clients can re-home them out of the main timeline (quiet-timeline
2571 // guarantee): the SDK forwards subagent tool_use blocks tagged with the
2572 // spawning Task tool's id as parent_tool_use_id.
2573 const parentToolUseId =
2574 (message as { parent_tool_use_id?: string | null }).parent_tool_use_id ?? undefined;
2575 const owningAgentId = agentIdForParentToolUse(context.taskAgents, parentToolUseId);
2576
2277 const tool: ToolInFlight = {2577 const tool: ToolInFlight = {
2278 itemId,2578 itemId,
2279 itemType,2579 itemType,
...@@ -2283,6 +2583,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2283,6 +2583,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2283 input: toolInput,2583 input: toolInput,
2284 partialInputJson: "",2584 partialInputJson: "",
2285 ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),2585 ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),
2586 ...(owningAgentId ? { agentId: owningAgentId } : {}),
2587 ...(parentToolUseId ? { parentToolUseId } : {}),
2286 };2588 };
2287 context.inFlightTools.set(index, tool);2589 context.inFlightTools.set(index, tool);
22882590
...@@ -2300,6 +2602,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2300,6 +2602,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2300 status: "inProgress",2602 status: "inProgress",
2301 title: tool.title,2603 title: tool.title,
2302 ...(tool.detail ? { detail: tool.detail } : {}),2604 ...(tool.detail ? { detail: tool.detail } : {}),
2605 ...(tool.agentId ? { agentId: tool.agentId } : {}),
2606 ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
2303 data: {2607 data: {
2304 toolName: tool.toolName,2608 toolName: tool.toolName,
2305 input: toolInput,2609 input: toolInput,
...@@ -2378,6 +2682,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2378,6 +2682,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2378 status: toolResult.isError ? "failed" : "inProgress",2682 status: toolResult.isError ? "failed" : "inProgress",
2379 title: tool.title,2683 title: tool.title,
2380 ...(tool.detail ? { detail: tool.detail } : {}),2684 ...(tool.detail ? { detail: tool.detail } : {}),
2685 ...(tool.agentId ? { agentId: tool.agentId } : {}),
2686 ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
2381 data: toolData,2687 data: toolData,
2382 },2688 },
2383 providerRefs: nativeProviderRefs(context, {2689 providerRefs: nativeProviderRefs(context, {
...@@ -2430,6 +2736,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2430,6 +2736,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2430 status: itemStatus,2736 status: itemStatus,
2431 title: tool.title,2737 title: tool.title,
2432 ...(tool.detail ? { detail: tool.detail } : {}),2738 ...(tool.detail ? { detail: tool.detail } : {}),
2739 ...(tool.agentId ? { agentId: tool.agentId } : {}),
2740 ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
2433 data: toolData,2741 data: toolData,
2434 },2742 },
2435 providerRefs: nativeProviderRefs(context, {2743 providerRefs: nativeProviderRefs(context, {
...@@ -2442,6 +2750,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2442,6 +2750,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2442 },2750 },
2443 });2751 });
24442752
2753 // The Workflow tool's result carries the run handles (runId, scriptPath,
2754 // transcriptDir, sessionUrl). Attach them to the workflow's task agent so
2755 // the next task.* payload advertises them to clients.
2756 if (!toolResult.isError && tool.toolName.toLowerCase() === "workflow" && toolUseResult) {
2757 const workflowTaskId = trimmedString(toolUseResult.taskId);
2758 if (workflowTaskId) {
2759 const runHandles: TaskRunHandles = {
2760 ...(trimmedString(toolUseResult.runId)
2761 ? { runId: trimmedString(toolUseResult.runId) }
2762 : {}),
2763 ...(trimmedString(toolUseResult.scriptPath)
2764 ? { scriptPath: trimmedString(toolUseResult.scriptPath) }
2765 : {}),
2766 ...(trimmedString(toolUseResult.transcriptDir)
2767 ? { transcriptDir: trimmedString(toolUseResult.transcriptDir) }
2768 : {}),
2769 ...(sanitizeSessionUrl(toolUseResult.sessionUrl)
2770 ? { sessionUrl: sanitizeSessionUrl(toolUseResult.sessionUrl) }
2771 : {}),
2772 };
2773 const existing = context.taskAgents.get(workflowTaskId);
2774 context.taskAgents.set(workflowTaskId, {
2775 taskId: workflowTaskId,
2776 toolUseId: existing?.toolUseId ?? tool.itemId,
2777 description: existing?.description,
2778 subagentType: existing?.subagentType,
2779 taskType: existing?.taskType ?? "local_workflow",
2780 workflowName: existing?.workflowName,
2781 skipTranscript: existing?.skipTranscript ?? false,
2782 runHandles,
2783 owningAgentId: existing?.owningAgentId,
2784 model: existing?.model,
2785 effort: existing?.effort,
2786 });
2787 }
2788 }
2789
2445 if (2790 if (
2446 !toolResult.isError &&2791 !toolResult.isError &&
2447 applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult)2792 applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult)
...@@ -2465,6 +2810,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2465,6 +2810,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2465 return;2810 return;
2466 }2811 }
24672812
2813 // Subagent-owned assistant snapshots (parent_tool_use_id set) are the
2814 // subagent's own conversation, not the parent's. Emitting them created
2815 // interleaved "Agent N done"-adjacent leak messages and spawned synthetic
2816 // turns per subagent completion (which also reset the Working timer).
2817 const assistantParentToolUseId = (message as { parent_tool_use_id?: string | null })
2818 .parent_tool_use_id;
2819 if (assistantParentToolUseId !== null && assistantParentToolUseId !== undefined) {
2820 // The snapshot's message.model is the authoritative API model the
2821 // subagent actually ran on — refine the seeded launch-time value.
2822 const owningTaskId = agentIdForParentToolUse(context.taskAgents, assistantParentToolUseId);
2823 const snapshotModel = trimmedString(message.message.model);
2824 const owningAgent = owningTaskId ? context.taskAgents.get(owningTaskId) : undefined;
2825 if (owningAgent && snapshotModel) {
2826 owningAgent.model = snapshotModel;
2827 }
2828 context.lastAssistantUuid = message.uuid;
2829 yield* updateResumeCursor(context);
2830 return;
2831 }
2832
2468 // Auto-start a synthetic turn for assistant messages that arrive without2833 // Auto-start a synthetic turn for assistant messages that arrive without
2469 // an active turn (e.g., background agent/subagent responses between user prompts).2834 // an active turn (e.g., background agent/subagent responses between user prompts).
2470 if (!context.turnState) {2835 if (!context.turnState) {
...@@ -2563,6 +2928,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2563,6 +2928,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2563 yield* completeTurn(context, status, errorMessage, message);2928 yield* completeTurn(context, status, errorMessage, message);
2564 });2929 });
25652930
2931 /**
2932 * Synthesizes per-member task.progress rows from the coordinator's
2933 * workflow_progress array. Member identity is the stable slot
2934 * `<coordinatorTaskId>:wf:<index>` (never the per-attempt agent id, which
2935 * changes on retry and would split one member into duplicate rows).
2936 * timelineBypass keeps these out of the parent chat; the Agents surface and
2937 * workflow card consume them.
2938 */
2939 const emitWorkflowMemberProgress = Effect.fn("emitWorkflowMemberProgress")(function* (
2940 context: ClaudeSessionContext,
2941 base: Omit<ProviderRuntimeEvent, "type" | "payload">,
2942 message: Extract<SDKMessage, { type: "system"; subtype: "task_progress" }>,
2943 ) {
2944 const progress = parseWorkflowProgress(
2945 (message as unknown as Record<string, unknown>).workflow_progress,
2946 );
2947 if (!progress) {
2948 return;
2949 }
2950 const coordinatorId = message.task_id;
2951 for (const entry of progress.agents) {
2952 const memberTaskId = `${coordinatorId}:wf:${entry.index}`;
2953 const status = workflowAgentStatus(entry);
2954 // Material-transition filter: the wire repeats every member each tick.
2955 // Emit only when something the client renders actually changed, so a
2956 // 100-agent fleet costs ~1 event per changed member instead of 100
2957 // per tick (review finding: unbounded event amplification).
2958 const fingerprint = [
2959 status,
2960 entry.label ?? "",
2961 entry.model ?? "",
2962 entry.lastToolName ?? "",
2963 entry.error ?? "",
2964 entry.tokens ?? "",
2965 entry.toolCalls ?? "",
2966 entry.phaseIndex ?? "",
2967 entry.phaseTitle ?? "",
2968 entry.attempt ?? "",
2969 ].join("\u001f");
2970 if (context.workflowMemberFingerprints.get(memberTaskId) === fingerprint) {
2971 continue;
2972 }
2973 context.workflowMemberFingerprints.set(memberTaskId, fingerprint);
2974 const stamp = yield* makeEventStamp();
2975 yield* offerRuntimeEvent({
2976 ...base,
2977 eventId: stamp.eventId,
2978 createdAt: stamp.createdAt,
2979 type: "task.progress",
2980 payload: {
2981 taskId: RuntimeTaskId.make(memberTaskId),
2982 description: entry.label ?? `agent ${entry.index}`,
2983 status,
2984 ...(entry.error ? { error: entry.error } : {}),
2985 ...(entry.label ? { title: entry.label } : {}),
2986 ...(entry.model ? { model: entry.model } : {}),
2987 ...(entry.lastToolName ? { lastToolName: entry.lastToolName } : {}),
2988 ...(entry.tokens !== undefined
2989 ? {
2990 typedUsage: {
2991 totalTokens: entry.tokens,
2992 ...(entry.toolCalls !== undefined ? { toolUses: entry.toolCalls } : {}),
2993 },
2994 }
2995 : {}),
2996 parentAgentId: coordinatorId,
2997 agentIndex: entry.index,
2998 ...(entry.phaseIndex !== undefined ? { phaseIndex: entry.phaseIndex } : {}),
2999 ...(entry.phaseTitle ? { phaseTitle: entry.phaseTitle } : {}),
3000 ...(entry.attempt !== undefined ? { attempt: entry.attempt } : {}),
3001 timelineBypass: true,
3002 },
3003 });
3004 }
3005 });
3006
2566 const handleSystemMessage = Effect.fn("handleSystemMessage")(function* (3007 const handleSystemMessage = Effect.fn("handleSystemMessage")(function* (
2567 context: ClaudeSessionContext,3008 context: ClaudeSessionContext,
2568 message: SDKMessage,3009 message: SDKMessage,
...@@ -2678,7 +3119,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2678,7 +3119,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2678 },3119 },
2679 });3120 });
2680 return;3121 return;
2681 case "task_started":3122 case "task_started": {
3123 // A task launched by a tool that itself ran inside a subagent (the
3124 // in-flight tool carries agentId from parent_tool_use_id) is
3125 // agent-internal: a subagent's background shell, not parent work.
3126 const launchingTool = message.tool_use_id
3127 ? Array.from(context.inFlightTools.values()).find(
3128 (tool) => tool.itemId === message.tool_use_id,
3129 )
3130 : undefined;
3131 const owningAgentId = launchingTool?.agentId;
3132 // Model/effort: the Agent tool's input carries explicit overrides;
3133 // absent ones inherit the session's selection (SDK behavior).
3134 // Subagent assistant snapshots later refine model with the
3135 // authoritative API id. AgentInput.effort may be a named level or an
3136 // integer.
3137 const launchInput = launchingTool?.input;
3138 const model =
3139 trimmedString(launchInput?.model) ?? trimmedString(context.session.model ?? undefined);
3140 const rawLaunchEffort = launchInput?.effort;
3141 const effort =
3142 trimmedString(rawLaunchEffort) ??
3143 (typeof rawLaunchEffort === "number" && Number.isFinite(rawLaunchEffort)
3144 ? String(rawLaunchEffort)
3145 : context.currentEffort);
3146 // Remember the agent identity so every later task.* payload for this
3147 // taskId is self-describing (identity must survive activity retention).
3148 context.taskAgents.set(message.task_id, {
3149 taskId: message.task_id,
3150 toolUseId: message.tool_use_id,
3151 description: message.description,
3152 subagentType: message.subagent_type,
3153 taskType: message.task_type,
3154 workflowName: message.workflow_name,
3155 skipTranscript: message.skip_transcript === true,
3156 runHandles: context.taskAgents.get(message.task_id)?.runHandles,
3157 owningAgentId,
3158 model,
3159 effort,
3160 });
3161 context.liveTaskIds.add(message.task_id);
2682 yield* offerRuntimeEvent({3162 yield* offerRuntimeEvent({
2683 ...base,3163 ...base,
2684 type: "task.started",3164 type: "task.started",
...@@ -2686,10 +3166,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2686,10 +3166,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2686 taskId: RuntimeTaskId.make(message.task_id),3166 taskId: RuntimeTaskId.make(message.task_id),
2687 description: message.description,3167 description: message.description,
2688 ...(message.task_type ? { taskType: message.task_type } : {}),3168 ...(message.task_type ? { taskType: message.task_type } : {}),
3169 ...(owningAgentId ? { agentId: owningAgentId } : {}),
3170 ...(message.description ? { title: message.description } : {}),
3171 ...(message.subagent_type ? { role: message.subagent_type } : {}),
3172 ...(model ? { model } : {}),
3173 ...(effort ? { effort } : {}),
3174 ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}),
3175 ...(message.workflow_name ? { workflowName: message.workflow_name } : {}),
2689 },3176 },
2690 });3177 });
2691 return;3178 return;
2692 case "task_progress":3179 }
3180 case "task_progress": {
2693 yield* emitThreadTokenUsage(3181 yield* emitThreadTokenUsage(
2694 context,3182 context,
2695 normalizeClaudeTaskProgressTokenUsage(message.usage, context),3183 normalizeClaudeTaskProgressTokenUsage(message.usage, context),
...@@ -2698,6 +3186,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2698,6 +3186,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2698 rawPayload: message,3186 rawPayload: message,
2699 },3187 },
2700 );3188 );
3189 const linkage = taskLinkageFor(context.taskAgents, message.task_id);
3190 const typedUsage = normalizeTaskUsage(message.usage);
3191 // Phases ride on the coordinator's ONE progress row per tick. A
3192 // separate phases-only row shared the stable ingestion activity id
3193 // with this full row, and the thinner upsert overwrote usage and
3194 // progress text (review finding).
3195 const workflowPhases = parseWorkflowProgress(
3196 (message as unknown as Record<string, unknown>).workflow_progress,
3197 )?.phases;
2701 yield* offerRuntimeEvent({3198 yield* offerRuntimeEvent({
2702 ...base,3199 ...base,
2703 type: "task.progress",3200 type: "task.progress",
...@@ -2706,16 +3203,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2706,16 +3203,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2706 description: message.description,3203 description: message.description,
2707 ...(message.summary ? { summary: message.summary } : {}),3204 ...(message.summary ? { summary: message.summary } : {}),
2708 ...(message.usage ? { usage: message.usage } : {}),3205 ...(message.usage ? { usage: message.usage } : {}),
3206 ...(typedUsage ? { typedUsage } : {}),
2709 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),3207 ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),
3208 ...(workflowPhases && workflowPhases.length > 0 ? { phases: workflowPhases } : {}),
3209 ...linkage,
3210 ...(message.subagent_type ? { role: message.subagent_type } : {}),
2710 },3211 },
2711 });3212 });
3213 yield* emitWorkflowMemberProgress(context, base, message);
2712 return;3214 return;
2713 // Task state patch (status/backgrounded/end_time). No runtime mapping3215 }
2714 // yet — the terminal task_notification reports the outcome — but it3216 case "task_updated": {
2715 // must not surface as an unknown-subtype warning row.3217 // Status patch (killed/paused/backgrounded/end_time/error) — main
2716 case "task_updated":3218 // previously dropped this on the floor, losing all transitions.
3219 const patch = message.patch;
3220 const status =
3221 patch.status !== undefined ? CLAUDE_TASK_PATCH_STATUS[patch.status] : undefined;
3222 if (status === "completed" || status === "failed" || status === "cancelled") {
3223 context.liveTaskIds.delete(message.task_id);
3224 }
3225 const endedAt =
3226 typeof patch.end_time === "number" && Number.isFinite(patch.end_time)
3227 ? DateTime.formatIso(DateTime.makeUnsafe(patch.end_time))
3228 : undefined;
3229 yield* offerRuntimeEvent({
3230 ...base,
3231 type: "task.updated",
3232 payload: {
3233 taskId: RuntimeTaskId.make(message.task_id),
3234 ...(status ? { status } : {}),
3235 ...(patch.description ? { description: patch.description } : {}),
3236 ...(patch.error ? { error: patch.error } : {}),
3237 ...(endedAt ? { endedAt } : {}),
3238 ...(patch.is_backgrounded !== undefined
3239 ? { isBackgrounded: patch.is_backgrounded }
3240 : {}),
3241 ...taskLinkageFor(context.taskAgents, message.task_id),
3242 },
3243 });
2717 return;3244 return;
2718 case "task_notification":3245 }
3246 case "task_notification": {
3247 context.liveTaskIds.delete(message.task_id);
2719 yield* emitThreadTokenUsage(3248 yield* emitThreadTokenUsage(
2720 context,3249 context,
2721 normalizeClaudeTaskProgressTokenUsage(message.usage, context),3250 normalizeClaudeTaskProgressTokenUsage(message.usage, context),
...@@ -2724,6 +3253,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2724,6 +3253,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2724 rawPayload: message,3253 rawPayload: message,
2725 },3254 },
2726 );3255 );
3256 const typedUsage = normalizeTaskUsage(message.usage);
2727 yield* offerRuntimeEvent({3257 yield* offerRuntimeEvent({
2728 ...base,3258 ...base,
2729 type: "task.completed",3259 type: "task.completed",
...@@ -2732,9 +3262,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2732,9 +3262,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2732 status: message.status,3262 status: message.status,
2733 ...(message.summary ? { summary: message.summary } : {}),3263 ...(message.summary ? { summary: message.summary } : {}),
2734 ...(message.usage ? { usage: message.usage } : {}),3264 ...(message.usage ? { usage: message.usage } : {}),
3265 ...(typedUsage ? { typedUsage } : {}),
3266 ...(message.output_file ? { outputFile: message.output_file } : {}),
3267 ...taskLinkageFor(context.taskAgents, message.task_id),
2735 },3268 },
2736 });3269 });
2737 return;3270 return;
3271 }
2738 case "files_persisted":3272 case "files_persisted":
2739 yield* offerRuntimeEvent({3273 yield* offerRuntimeEvent({
2740 ...base,3274 ...base,
...@@ -2870,7 +3404,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2870,7 +3404,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2870 toolUseId: message.tool_use_id,3404 toolUseId: message.tool_use_id,
2871 toolName: message.tool_name,3405 toolName: message.tool_name,
2872 elapsedSeconds: message.elapsed_time_seconds,3406 elapsedSeconds: message.elapsed_time_seconds,
2873 ...(message.task_id ? { summary: `task:${message.task_id}` } : {}),3407 ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}),
3408 ...(message.parent_tool_use_id !== null
3409 ? { parentToolUseId: message.parent_tool_use_id }
3410 : {}),
2874 },3411 },
2875 });3412 });
2876 return;3413 return;
...@@ -3192,6 +3729,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3192,6 +3729,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3192 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();3729 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
3193 const inFlightTools = new Map<number, ToolInFlight>();3730 const inFlightTools = new Map<number, ToolInFlight>();
3194 const claudeTasks = new Map<string, ClaudeTaskState>();3731 const claudeTasks = new Map<string, ClaudeTaskState>();
3732 const taskAgents = new Map<string, ClaudeTaskAgentState>();
3733 const workflowMemberFingerprints = new Map<string, string>();
3734 const liveTaskIds = new Set<string>();
31953735
3196 const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined);3736 const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined);
31973737
...@@ -3628,12 +4168,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3628,12 +4168,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3628 startedAt,4168 startedAt,
3629 basePermissionMode: permissionMode,4169 basePermissionMode: permissionMode,
3630 currentApiModelId: apiModelId,4170 currentApiModelId: apiModelId,
4171 currentEffort: effectiveEffort ?? undefined,
3631 resumeSessionId: sessionId,4172 resumeSessionId: sessionId,
3632 pendingApprovals,4173 pendingApprovals,
3633 pendingUserInputs,4174 pendingUserInputs,
3634 turns: [],4175 turns: [],
3635 inFlightTools,4176 inFlightTools,
3636 claudeTasks,4177 claudeTasks,
4178 taskAgents,
4179 workflowMemberFingerprints,
4180 liveTaskIds,
3637 turnState: undefined,4181 turnState: undefined,
3638 lastKnownContextWindow: initialContextWindow,4182 lastKnownContextWindow: initialContextWindow,
3639 lastKnownTokenUsage: undefined,4183 lastKnownTokenUsage: undefined,
...@@ -3750,6 +4294,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3750,6 +4294,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3750 ...context.session,4294 ...context.session,
3751 model: modelSelection.model,4295 model: modelSelection.model,
3752 };4296 };
4297 const turnCaps = getClaudeModelCapabilities(modelSelection.model);
4298 const turnEffort = resolveClaudeEffort(
4299 turnCaps,
4300 getModelSelectionStringOptionValue(modelSelection, "effort"),
4301 );
4302 context.currentEffort =
4303 getEffectiveClaudeAgentEffort(turnEffort ?? null, modelSelection.model) ?? undefined;
3753 }4304 }
37544305
3755 // Apply interaction mode by switching the SDK's permission mode.4306 // Apply interaction mode by switching the SDK's permission mode.
...@@ -3825,6 +4376,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3825,6 +4376,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3825 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(4376 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
3826 function* (threadId, _turnId) {4377 function* (threadId, _turnId) {
3827 const context = yield* requireSession(threadId);4378 const context = yield* requireSession(threadId);
4379 // Stop-everything semantics: users reach for Stop precisely when a
4380 // fleet ran away. interrupt() alone only ends the parent turn —
4381 // background subagents/shells keep running and keep burning tokens.
4382 // Stop every live task first (best-effort per task: one refusal must
4383 // not strand the rest or block the turn interrupt), then interrupt.
4384 if (context.query.stopTask && context.liveTaskIds.size > 0) {
4385 const liveIds = Array.from(context.liveTaskIds);
4386 // Bounded: a wedged child's stopTask promise may never settle
4387 // (Effect.ignore handles rejection, not non-resolution), and the
4388 // parent interrupt below MUST still run — Stop matters most during
4389 // runaway fleets (review finding). Per-task timeout keeps one hung
4390 // child from consuming the whole budget.
4391 yield* Effect.forEach(
4392 liveIds,
4393 (taskId) =>
4394 Effect.tryPromise({
4395 // Invoke through the query object: SDK methods rely on `this`.
4396 try: () => context.query.stopTask!(taskId),
4397 catch: () => undefined,
4398 }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore),
4399 { concurrency: 8, discard: true },
4400 ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore);
4401 }
3828 yield* Effect.tryPromise({4402 yield* Effect.tryPromise({
3829 try: () => context.query.interrupt(),4403 try: () => context.query.interrupt(),
3830 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),4404 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
apps/server/src/provider/Layers/CodexAdapter.ts+268
...@@ -20,6 +20,8 @@ import {...@@ -20,6 +20,8 @@ import {
20 type ProviderUserInputAnswers,20 type ProviderUserInputAnswers,
21 RuntimeItemId,21 RuntimeItemId,
22 RuntimeRequestId,22 RuntimeRequestId,
23 RuntimeTaskId,
24 type RuntimeTaskUsage,
23 ProviderApprovalDecision,25 ProviderApprovalDecision,
24 ThreadId,26 ThreadId,
25 ProviderSendTurnInput,27 ProviderSendTurnInput,
...@@ -494,10 +496,276 @@ function mapItemLifecycle(...@@ -494,10 +496,276 @@ function mapItemLifecycle(
494 };496 };
495}497}
496498
499/**
500 * Maps the session runtime's synthetic `collabAgent/*` events (native
501 * multi-agent v2 child-thread signals) into the shared task.* lifecycle.
502 * Agent identity = child thread id; nickname is the display title, role is
503 * agentRole (fallback: last agentPath segment, then "general-purpose").
504 * A completed child turn is idle (resumable), not terminal. timelineBypass
505 * keeps these rows out of the parent chat.
506 */
507function mapCollabAgentEvent(
508 event: ProviderEvent,
509 canonicalThreadId: ThreadId,
510): ReadonlyArray<ProviderRuntimeEvent> {
511 const payload =
512 typeof event.payload === "object" && event.payload !== null
513 ? (event.payload as Record<string, unknown>)
514 : undefined;
515 const agentThreadId = typeof payload?.agentThreadId === "string" ? payload.agentThreadId : "";
516 if (!payload || agentThreadId.length === 0) {
517 return [];
518 }
519 const base = runtimeEventBase(event, canonicalThreadId);
520 const taskId = RuntimeTaskId.make(agentThreadId);
521 const agentPath = typeof payload.agentPath === "string" ? payload.agentPath : undefined;
522 const pathLeaf = agentPath?.split("/").findLast((segment) => segment.length > 0);
523 const nickname = typeof payload.nickname === "string" ? payload.nickname : undefined;
524 const role =
525 (typeof payload.role === "string" ? payload.role : undefined) ?? pathLeaf ?? "general-purpose";
526 // A bare thread id is not a name. Omitting the title lets the client fold
527 // keep the real one from task.started instead of clobbering it (probe
528 // finding: progress rows renamed math_one to its UUID).
529 const knownName = nickname ?? pathLeaf;
530 const title = knownName ?? agentThreadId;
531 // Identity repeated on every status patch so rows are self-describing when
532 // the start row ages out of activity retention (review finding: a
533 // reconstructed agent had a UUID name and no role/path).
534 const statusLinkage = {
535 role,
536 ...(knownName ? { title: knownName } : {}),
537 ...(agentPath ? { agentPath } : {}),
538 timelineBypass: true,
539 } as const;
540
541 switch (event.method) {
542 case "collabAgent/started":
543 return [
544 {
545 ...base,
546 type: "task.started",
547 payload: {
548 taskId,
549 description: title,
550 title,
551 role,
552 ...(agentPath ? { agentPath } : {}),
553 ...(typeof payload.parentThreadId === "string"
554 ? { parentAgentId: payload.parentThreadId }
555 : {}),
556 timelineBypass: true,
557 },
558 },
559 ];
560 case "collabAgent/activity": {
561 const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : "";
562 if (activityKind === "interrupted") {
563 return [
564 {
565 ...base,
566 type: "task.updated",
567 payload: { taskId, status: "interrupted", ...statusLinkage },
568 },
569 ];
570 }
571 if (activityKind === "started") {
572 // Wire-probe finding: children often register via subAgentActivity
573 // alone (no thread/started with a spawn source), so this is the one
574 // shot at a task.started with a real name — agentPath leaf beats a
575 // bare thread-id title.
576 return [
577 {
578 ...base,
579 type: "task.started",
580 payload: {
581 taskId,
582 description: title,
583 title,
584 role,
585 ...(agentPath ? { agentPath } : {}),
586 timelineBypass: true,
587 },
588 },
589 ];
590 }
591 // interacted → the child is (again) actively driven.
592 return [
593 {
594 ...base,
595 type: "task.updated",
596 payload: { taskId, status: "running", ...statusLinkage },
597 },
598 ];
599 }
600 case "collabAgent/turnStarted":
601 return [
602 {
603 ...base,
604 type: "task.updated",
605 payload: { taskId, status: "running", ...statusLinkage },
606 },
607 ];
608 case "collabAgent/turnCompleted": {
609 // Idle, not terminal: the identity is resumable via sendInput/resume.
610 const turn =
611 typeof payload.turn === "object" && payload.turn !== null
612 ? (payload.turn as Record<string, unknown>)
613 : undefined;
614 const turnStatus = typeof turn?.status === "string" ? turn.status : undefined;
615 const status =
616 turnStatus === "failed"
617 ? ("failed" as const)
618 : turnStatus === "interrupted"
619 ? ("interrupted" as const)
620 : ("idle" as const);
621 return [
622 {
623 ...base,
624 type: "task.updated",
625 payload: { taskId, status, ...statusLinkage },
626 },
627 ];
628 }
629 case "collabAgent/statusChanged": {
630 const status =
631 typeof payload.status === "object" && payload.status !== null
632 ? (payload.status as Record<string, unknown>)
633 : undefined;
634 const statusType = typeof status?.type === "string" ? status.type : undefined;
635 if (statusType === "systemError") {
636 // Silently dropping this once left children stuck running forever.
637 return [
638 {
639 ...base,
640 type: "task.updated",
641 payload: { taskId, status: "failed", ...statusLinkage },
642 },
643 ];
644 }
645 if (statusType === "active") {
646 const flags = Array.isArray(status?.activeFlags) ? status.activeFlags : [];
647 const waiting = flags.some(
648 (flag) => flag === "waitingOnApproval" || flag === "waitingOnUserInput",
649 );
650 return [
651 {
652 ...base,
653 type: "task.updated",
654 payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage },
655 },
656 ];
657 }
658 if (statusType === "idle") {
659 return [
660 {
661 ...base,
662 type: "task.updated",
663 payload: { taskId, status: "idle", ...statusLinkage },
664 },
665 ];
666 }
667 return [];
668 }
669 case "collabAgent/tokenUsage": {
670 // Cumulative per child thread: always the `total` breakdown, never
671 // `last` (which shrinks on follow-ups). Client folds max-merge.
672 const tokenUsage =
673 typeof payload.tokenUsage === "object" && payload.tokenUsage !== null
674 ? (payload.tokenUsage as Record<string, unknown>)
675 : undefined;
676 const total =
677 typeof tokenUsage?.total === "object" && tokenUsage.total !== null
678 ? (tokenUsage.total as Record<string, unknown>)
679 : undefined;
680 const count = (value: unknown): number | undefined =>
681 typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
682 // Same validation as every other field: RuntimeTaskUsage.totalTokens
683 // is NonNegativeInt, so NaN/Infinity/negative wire values must miss.
684 const totalTokens = count(total?.totalTokens);
685 if (totalTokens === undefined) {
686 return [];
687 }
688 const typedUsage: RuntimeTaskUsage = {
689 totalTokens,
690 ...(count(total?.inputTokens) !== undefined
691 ? { inputTokens: count(total?.inputTokens) }
692 : {}),
693 ...(count(total?.cachedInputTokens) !== undefined
694 ? { cachedInputTokens: count(total?.cachedInputTokens) }
695 : {}),
696 ...(count(total?.outputTokens) !== undefined
697 ? { outputTokens: count(total?.outputTokens) }
698 : {}),
699 ...(count(total?.reasoningOutputTokens) !== undefined
700 ? { reasoningOutputTokens: count(total?.reasoningOutputTokens) }
701 : {}),
702 };
703 return [
704 {
705 ...base,
706 type: "task.progress",
707 payload: {
708 taskId,
709 description: title,
710 ...(knownName ? { title: knownName } : {}),
711 typedUsage,
712 timelineBypass: true,
713 },
714 },
715 ];
716 }
717 case "collabAgent/item": {
718 const item =
719 typeof payload.item === "object" && payload.item !== null
720 ? (payload.item as Record<string, unknown>)
721 : undefined;
722 const itemTypeRaw = typeof item?.type === "string" ? item.type : undefined;
723 if (!itemTypeRaw) {
724 return [];
725 }
726 // A loose summary from the raw item: the child stream is untyped at
727 // this boundary (synthetic event payload), so read best-effort fields
728 // rather than force a schema decode.
729 const looseSummary =
730 (typeof item?.command === "string" ? item.command : undefined) ??
731 (typeof item?.title === "string" ? item.title : undefined) ??
732 (typeof item?.query === "string" ? item.query : undefined);
733 const canonical = toCanonicalItemType(itemTypeRaw);
734 const summary = looseSummary ?? canonical.replaceAll("_", " ");
735 return [
736 {
737 ...base,
738 type: "task.progress",
739 payload: {
740 taskId,
741 description: title,
742 ...(knownName ? { title: knownName } : {}),
743 summary,
744 timelineBypass: true,
745 },
746 },
747 ];
748 }
749 case "collabAgent/closed":
750 return [
751 {
752 ...base,
753 type: "task.updated",
754 payload: { taskId, status: "interrupted", ...statusLinkage },
755 },
756 ];
757 default:
758 return [];
759 }
760}
761
497function mapToRuntimeEvents(762function mapToRuntimeEvents(
498 event: ProviderEvent,763 event: ProviderEvent,
499 canonicalThreadId: ThreadId,764 canonicalThreadId: ThreadId,
500): ReadonlyArray<ProviderRuntimeEvent> {765): ReadonlyArray<ProviderRuntimeEvent> {
766 if (event.kind === "notification" && event.method.startsWith("collabAgent/")) {
767 return mapCollabAgentEvent(event, canonicalThreadId);
768 }
501 if (event.kind === "error") {769 if (event.kind === "error") {
502 if (!event.message) {770 if (!event.message) {
503 return [];771 return [];
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts created+248
...@@ -0,0 +1,248 @@
1/**
2 * Runtime-level collab regression: boots the REAL CodexSessionRuntime against
3 * a scripted mock app-server peer that replays the captured multi-agent wire
4 * sequence (codexMultiAgentWire.json) plus the shapes the capture alone can't
5 * script (receiver-turn bookkeeping via collabAgentToolCall, child terminal
6 * lifecycle, approval pass-through). This is the layer the pure routing-table
7 * test can't reach: ordering between the legacy receiver-turn suppressor and
8 * v2 interception, registration state, and synthetic event emission.
9 */
10// @effect-diagnostics nodeBuiltinImport:off
11import * as NodeFS from "node:fs";
12import * as NodePath from "node:path";
13
14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { it } from "@effect/vitest";
16import { ThreadId } from "@t3tools/contracts";
17import * as Effect from "effect/Effect";
18import * as Fiber from "effect/Fiber";
19import * as Stream from "effect/Stream";
20import { assert, describe } from "vite-plus/test";
21
22import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" };
23import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts";
24
25const ROOT = wireFixture.rootThreadId;
26const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string];
27
28/**
29 * The captured sequence, extended with the shapes the live capture didn't
30 * include: a collabAgentToolCall with receiverThreadIds (feeds the legacy
31 * receiver-turn map, so ordering vs. v2 interception is exercised), child
32 * terminal lifecycle, and a serverRequest/resolved addressed to a child
33 * (must pass through to the parent path, not vanish).
34 */
35function buildScript() {
36 const captured = wireFixture.notifications;
37 const extras = [
38 {
39 method: "item/completed",
40 params: {
41 threadId: ROOT,
42 item: {
43 type: "collabAgentToolCall",
44 id: "call_fixture_wait",
45 tool: "wait",
46 status: "completed",
47 senderThreadId: ROOT,
48 receiverThreadIds: [CHILD_A, CHILD_B],
49 },
50 },
51 },
52 // Child terminal lifecycle AFTER the receiver map knows the children —
53 // pre-fix, the legacy suppressor dropped these before interception saw
54 // them, so no synthetic agent events were emitted.
55 {
56 method: "turn/completed",
57 params: {
58 threadId: CHILD_A,
59 turn: { id: `${CHILD_A}-turn-1`, status: "completed", items: [] },
60 },
61 },
62 { method: "thread/closed", params: { threadId: CHILD_B } },
63 // Parent-owned traffic addressed to a child conversation: must reach the
64 // parent path (approval correlation cleanup), not be swallowed.
65 { method: "serverRequest/resolved", params: { threadId: CHILD_A, requestId: "req-1" } },
66 ];
67 return {
68 rootThreadId: ROOT,
69 notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras],
70 };
71}
72
73const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json");
74const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh");
75
76describe("CodexSessionRuntime collab integration", () => {
77 it.effect("replays the captured fan-out into synthetic agent events without child leaks", () =>
78 Effect.gen(function* () {
79 // @effect-diagnostics-next-line preferSchemaOverJson:off
80 NodeFS.writeFileSync(scriptPath, JSON.stringify(buildScript()), "utf8");
81 yield* Effect.addFinalizer(() =>
82 Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })),
83 );
84
85 const runtime = yield* makeCodexSessionRuntime({
86 threadId: ThreadId.make("thread-collab-integration"),
87 binaryPath: peerPath,
88 cwd: "/tmp",
89 runtimeMode: "full-access",
90 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
91 });
92
93 const eventsFiber = yield* runtime.events.pipe(
94 Stream.takeUntil((event) => event.method === "turn/completed"),
95 Stream.runCollect,
96 Effect.forkScoped,
97 );
98
99 yield* runtime.start();
100 yield* runtime.sendTurn({ input: "fan out" });
101
102 const events = Array.from(yield* Fiber.join(eventsFiber));
103 const methods = events.map((event) => event.method);
104
105 // Children registered from subAgentActivity become synthetic agent
106 // lifecycle — including terminal rows that arrive AFTER the receiver
107 // map knows them (the ordering this test exists to pin).
108 assert.include(methods, "collabAgent/activity");
109 assert.include(methods, "collabAgent/turnCompleted");
110 assert.include(methods, "collabAgent/closed");
111
112 const childTurnCompleted = events.find(
113 (event) =>
114 event.method === "collabAgent/turnCompleted" &&
115 (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A,
116 );
117 assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event");
118
119 const childClosed = events.find(
120 (event) =>
121 event.method === "collabAgent/closed" &&
122 (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B,
123 );
124 assert.isDefined(childClosed, "child B's close becomes an agent event");
125
126 // Parent-owned resolution passes through — not swallowed, not
127 // re-labelled as an agent event.
128 assert.include(methods, "serverRequest/resolved");
129
130 // The root's own subAgentActivity about "/root" must NOT register the
131 // root as a child: the parent turn completion still flows.
132 assert.include(methods, "turn/completed");
133
134 // No raw child conversation methods leak onto the parent stream.
135 const leaked = events.filter((event) => {
136 const payload = event.payload as { threadId?: string } | undefined;
137 const addressedToChild = payload?.threadId === CHILD_A || payload?.threadId === CHILD_B;
138 return addressedToChild && (event.method?.startsWith("thread/") ?? false);
139 });
140 assert.deepEqual(
141 leaked.map((event) => event.method),
142 [],
143 "child thread/* lifecycle must not appear as parent events",
144 );
145
146 yield* runtime.close;
147 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
148 );
149
150 // it.live: the runtime talks to a real child process; under it.effect's
151 // TestClock the internal timers freeze and the join never completes.
152 it.live("Stop interrupts every live child regardless of registration timing", () =>
153 Effect.gen(function* () {
154 // Ordering + liveness torture for stop-everything: child A's
155 // turn/started arrives BEFORE anything registers it (foreign
156 // suppression path must record the live turn); child B's arrives after
157 // registration; child A's interrupt HANGS (RPC never settles — worse
158 // than rejecting) and the bounded deadline must still deliver B's and
159 // the parent's interrupts. The turn stays open so children are live
160 // when Stop fires.
161 // Build from REAL captured rows (hand-written shapes fail notification
162 // schema validation and are silently dropped): reorder so child A's
163 // turn/started precedes its registration, and drop terminal rows so
164 // children stay live when Stop fires.
165 const byIndex = wireFixture.notifications;
166 const isTurnStarted = (entry: (typeof byIndex)[number], child: string) =>
167 entry.method === "turn/started" &&
168 (entry.params as { threadId?: string }).threadId === child;
169 const isRegistration = (entry: (typeof byIndex)[number], child: string) => {
170 const item = (entry.params as { item?: { type?: string; agentThreadId?: string } }).item;
171 return item?.type === "subAgentActivity" && item.agentThreadId === child;
172 };
173 const turnStartedA = byIndex.find((entry) => isTurnStarted(entry, CHILD_A));
174 const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B));
175 const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A));
176 const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B));
177 assert.isDefined(turnStartedA);
178 assert.isDefined(turnStartedB);
179 assert.isDefined(registrationA);
180 assert.isDefined(registrationB);
181 const script = {
182 rootThreadId: ROOT,
183 holdTurnOpen: true,
184 hangInterruptFor: CHILD_A,
185 notifications: [turnStartedA, registrationA, registrationB, turnStartedB],
186 };
187 // @effect-diagnostics-next-line preferSchemaOverJson:off
188 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
189 const interruptsPath = `${scriptPath}.interrupts`;
190 NodeFS.rmSync(interruptsPath, { force: true });
191 yield* Effect.addFinalizer(() =>
192 Effect.sync(() => {
193 NodeFS.rmSync(scriptPath, { force: true });
194 NodeFS.rmSync(interruptsPath, { force: true });
195 }),
196 );
197
198 const runtime = yield* makeCodexSessionRuntime({
199 threadId: ThreadId.make("thread-collab-stop"),
200 binaryPath: peerPath,
201 cwd: "/tmp",
202 runtimeMode: "full-access",
203 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
204 });
205
206 // Wait for both children's turnStarted signals to be processed before
207 // stopping (B via the registered-child path; A only produces live-turn
208 // bookkeeping, so key on B's synthetic event).
209 const childBStartedFiber = yield* runtime.events.pipe(
210 Stream.filter(
211 (event) =>
212 event.method === "collabAgent/turnStarted" &&
213 (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B,
214 ),
215 Stream.take(1),
216 Stream.runCollect,
217 Effect.forkScoped,
218 );
219
220 yield* runtime.start();
221 yield* runtime.sendTurn({ input: "fan out and hang" });
222 const childBStarted = yield* Fiber.join(childBStartedFiber).pipe(
223 Effect.timeoutOption("15 seconds"),
224 );
225 assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived");
226
227 // Stop everything. A's interrupt hangs forever — the bounded child
228 // deadline must expire and the parent interrupt must still be sent.
229 yield* runtime.interruptTurn();
230
231 const parseInterruptLine = (line: string) => JSON.parse(line) as { threadId?: string };
232 const interrupted = NodeFS.readFileSync(interruptsPath, "utf8")
233 .trim()
234 .split("\n")
235 .filter((line) => line.length > 0)
236 .map(parseInterruptLine);
237 const interruptedThreads = new Set(interrupted.map((entry) => entry.threadId));
238 assert.isTrue(
239 interruptedThreads.has(CHILD_A),
240 "pre-registration child A must still receive the interrupt RPC",
241 );
242 assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted");
243 assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last");
244
245 yield* runtime.close;
246 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
247 );
248});
apps/server/src/provider/Layers/CodexCollabWire.test.ts created+180
...@@ -0,0 +1,180 @@
1/**
2 * Codex multi-agent wire fixtures.
3 *
4 * The child-interception path is the highest-blast-radius code in the
5 * subagent stack: it decides, per notification, whether traffic reaches the
6 * parent timeline at all. Three shipped bugs came from that decision being
7 * implicit (root thread registered as its own child; `error` and
8 * `serverRequest/resolved` swallowed by a catch-all). These tests pin the
9 * decision against a REAL capture rather than hand-written shapes.
10 *
11 * Fixture provenance: codex-cli 0.145.0 driven directly over stdio with
12 * gpt-5.6-luna at low effort, prompting a two-child fan-out (alpha, beta).
13 * See codexMultiAgentWire.json.
14 */
15import { assert, describe, it } from "vite-plus/test";
16
17import fixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" };
18import { routeCodexChildNotification } from "./CodexSessionRuntime.ts";
19
20interface WireNotification {
21 readonly method: string;
22 readonly params: Record<string, unknown>;
23}
24
25const notifications = fixture.notifications as ReadonlyArray<WireNotification>;
26const rootThreadId = fixture.rootThreadId;
27const childThreadIds = new Set(fixture.childThreadIds);
28
29/** Mirrors readNotificationThreadId's addressing for the captured methods. */
30function notificationThreadId(entry: WireNotification): string | undefined {
31 const params = entry.params;
32 const thread = params.thread;
33 if (
34 typeof thread === "object" &&
35 thread !== null &&
36 typeof (thread as { id?: unknown }).id === "string"
37 ) {
38 return (thread as { id: string }).id;
39 }
40 return typeof params.threadId === "string" ? params.threadId : undefined;
41}
42
43function subAgentActivityItems(): ReadonlyArray<Record<string, unknown>> {
44 return notifications.flatMap((entry) => {
45 const item = entry.params.item;
46 if (typeof item !== "object" || item === null) return [];
47 const record = item as Record<string, unknown>;
48 return record.type === "subAgentActivity" ? [record] : [];
49 });
50}
51
52describe("codex multi-agent wire capture", () => {
53 it("captures a real two-child fan-out", () => {
54 assert.equal(fixture.capturedWith.model, "gpt-5.6-luna");
55 assert.equal(childThreadIds.size, 2);
56 const paths = subAgentActivityItems().map((item) => item.agentPath);
57 assert.include(paths, "/root/alpha");
58 assert.include(paths, "/root/beta");
59 });
60
61 it("emits child traffic BEFORE the item that registers the child", () => {
62 // Ordering hazard: the child's own thread/status/changed arrives before
63 // the parent-side subAgentActivity naming it. Registration must tolerate
64 // child-first arrival, so unregistered child traffic passes through
65 // rather than being eaten (no regression vs. pre-feature behavior).
66 const firstChildTraffic = notifications.findIndex((entry) => {
67 const threadId = notificationThreadId(entry);
68 return threadId !== undefined && childThreadIds.has(threadId);
69 });
70 const firstRegistration = notifications.findIndex((entry) => {
71 const item = entry.params.item;
72 if (typeof item !== "object" || item === null) return false;
73 const record = item as Record<string, unknown>;
74 return record.type === "subAgentActivity" && record.kind === "started";
75 });
76 assert.isAtLeast(firstChildTraffic, 0);
77 assert.isAtLeast(firstRegistration, 0);
78 assert.isBelow(
79 firstChildTraffic,
80 firstRegistration,
81 "capture should exercise child-first ordering",
82 );
83 });
84
85 it("contains a /root self-activity emitted from a CHILD thread", () => {
86 // The bug this guards: the wire reports subAgentActivity about the ROOT
87 // (agentPath "/root"). Registering it made the runtime intercept the
88 // parent's own final message and turn/completed, so the thread hung
89 // "working" forever. The root guard must key on agentPath/thread id,
90 // never on which thread the notification arrived from.
91 const rootSelfActivity = subAgentActivityItems().find((item) => item.agentPath === "/root");
92 assert.isDefined(rootSelfActivity, "capture should contain a /root self-activity");
93 assert.equal(rootSelfActivity?.agentThreadId, rootThreadId);
94 });
95
96 it("routes every captured child method to a defined disposition", () => {
97 const childMethods = new Set(
98 notifications
99 .filter((entry) => {
100 const threadId = notificationThreadId(entry);
101 return threadId !== undefined && childThreadIds.has(threadId);
102 })
103 .map((entry) => entry.method),
104 );
105 assert.isAbove(childMethods.size, 0);
106 for (const method of childMethods) {
107 const route = routeCodexChildNotification(method);
108 // Child lifecycle traffic must become agent events — never silently
109 // dropped, never leaked to the parent timeline.
110 assert.equal(route, "agent-event", `${method} should map to an agent event`);
111 }
112 });
113});
114
115describe("routeCodexChildNotification", () => {
116 it("maps child lifecycle to agent events", () => {
117 for (const method of [
118 "turn/started",
119 "turn/completed",
120 "thread/status/changed",
121 "thread/tokenUsage/updated",
122 "item/started",
123 "item/completed",
124 "thread/closed",
125 "error",
126 ]) {
127 assert.equal(routeCodexChildNotification(method), "agent-event", method);
128 }
129 });
130
131 it("drops only enumerated child chatter", () => {
132 for (const method of [
133 "item/agentMessage/delta",
134 "item/reasoning/textDelta",
135 "item/commandExecution/outputDelta",
136 "turn/plan/updated",
137 "thread/name/updated",
138 ]) {
139 assert.equal(routeCodexChildNotification(method), "drop", method);
140 }
141 });
142
143 it("never routes child-owned thread lifecycle to the parent", () => {
144 // These mutate PARENT thread state in CodexAdapter (archived/compacted),
145 // so a child emitting them must never reach the parent path. This list
146 // mirrors shouldSuppressChildConversationNotification (the v1 collab
147 // suppressor) — the two must not drift (review finding: the router
148 // initially omitted them and they leaked).
149 for (const method of [
150 "thread/started",
151 "thread/status/changed",
152 "thread/archived",
153 "thread/unarchived",
154 "thread/closed",
155 "thread/compacted",
156 "thread/name/updated",
157 "thread/tokenUsage/updated",
158 "turn/started",
159 "turn/completed",
160 "turn/plan/updated",
161 "item/plan/delta",
162 ]) {
163 assert.notEqual(
164 routeCodexChildNotification(method),
165 "parent",
166 `${method} is child-owned and must not reach the parent path`,
167 );
168 }
169 });
170
171 it("sends parent-owned and UNKNOWN methods to the parent path", () => {
172 // serverRequest/resolved clears the parent's approval correlation:
173 // swallowing it left approvals stuck (shipped bug). Unknown methods take
174 // the same route by design — a codex update that adds a notification
175 // must degrade to "parent sees it", never to silent loss.
176 assert.equal(routeCodexChildNotification("serverRequest/resolved"), "parent");
177 assert.equal(routeCodexChildNotification("thread/somethingBrandNew"), "parent");
178 assert.equal(routeCodexChildNotification("account/rateLimits/updated"), "parent");
179 });
180});
apps/server/src/provider/Layers/CodexSessionRuntime.ts+490-1
...@@ -600,6 +600,71 @@ function readRouteFields(notification: CodexServerNotification): {...@@ -600,6 +600,71 @@ function readRouteFields(notification: CodexServerNotification): {
600 }600 }
601}601}
602602
603/**
604 * Native collab child-agent tracking (multi-agent v2). Under v2 subagents are
605 * full app-server threads: identity arrives on `thread/started` with
606 * source.subAgent.thread_spawn, lifecycle on `subAgentActivity` items and the
607 * child thread's own turn/status/tokenUsage notifications. The runtime
608 * registers children from those explicit signals, intercepts their
609 * notifications before parent-timeline mapping, and re-emits them as
610 * synthetic `collabAgent/*` provider events the adapter turns into task.*
611 * runtime events (timelineBypass keeps them out of the parent chat).
612 *
613 * WIP, probe-gated: registration is deliberately explicit-signals-only. The
614 * spec's "provisionally treat unknown foreign thread ids as v2 children" rule
615 * needs a live wire capture of the packaged binary before it lands — blind
616 * capture risks eating unrelated traffic. Until then a child whose first
617 * notification precedes registration passes through as today (no regression
618 * vs main, which passes everything through).
619 */
620interface CollabChildAgentState {
621 readonly agentThreadId: string;
622 readonly nickname: string | undefined;
623 readonly role: string | undefined;
624 readonly agentPath: string | undefined;
625 readonly depth: number | undefined;
626 readonly parentThreadId: string | undefined;
627 /**
628 * Parent canonical turn active when the child registered. Stamped on every
629 * synthetic collabAgent/* event so clients can batch a fleet by its spawn
630 * turn — without it, separate fleets in one thread collapsed into a single
631 * "direct:no-turn" CTA (review finding).
632 */
633 readonly spawnTurnId: TurnId | undefined;
634}
635
636function readThreadSpawnSource(thread: { readonly source: unknown }):
637 | {
638 nickname: string | undefined;
639 role: string | undefined;
640 agentPath: string | undefined;
641 depth: number | undefined;
642 parentThreadId: string | undefined;
643 }
644 | undefined {
645 const source = thread.source;
646 if (typeof source !== "object" || source === null || !("subAgent" in source)) {
647 return undefined;
648 }
649 const subAgent = (source as { subAgent: unknown }).subAgent;
650 if (typeof subAgent !== "object" || subAgent === null || !("thread_spawn" in subAgent)) {
651 return undefined;
652 }
653 const spawn = (subAgent as { thread_spawn: unknown }).thread_spawn;
654 if (typeof spawn !== "object" || spawn === null) {
655 return undefined;
656 }
657 const record = spawn as Record<string, unknown>;
658 return {
659 nickname: typeof record.agent_nickname === "string" ? record.agent_nickname : undefined,
660 role: typeof record.agent_role === "string" ? record.agent_role : undefined,
661 agentPath: typeof record.agent_path === "string" ? record.agent_path : undefined,
662 depth: typeof record.depth === "number" ? record.depth : undefined,
663 parentThreadId:
664 typeof record.parent_thread_id === "string" ? record.parent_thread_id : undefined,
665 };
666}
667
603function rememberCollabReceiverTurns(668function rememberCollabReceiverTurns(
604 collabReceiverTurns: Map<string, TurnId>,669 collabReceiverTurns: Map<string, TurnId>,
605 notification: CodexServerNotification,670 notification: CodexServerNotification,
...@@ -641,6 +706,72 @@ function shouldSuppressChildConversationNotification(...@@ -641,6 +706,72 @@ function shouldSuppressChildConversationNotification(
641 );706 );
642}707}
643708
709/**
710 * How a notification addressed to a REGISTERED child thread is handled.
711 *
712 * Exported and pure so the routing table can be asserted against captured
713 * wire traces (see codexMultiAgentWire.json) rather than only read.
714 *
715 * - "agent-event": map to a synthetic collabAgent/* event (Agents surface).
716 * - "parent": pass through to the parent path — it carries state the parent
717 * still owns (approval correlation cleanup).
718 * - "drop": genuine child chatter with no parent meaning (deltas, name and
719 * plan updates).
720 *
721 * Default is "drop" ONLY for the enumerated chatter; anything unrecognized
722 * routes to "parent" so new wire methods surface instead of vanishing
723 * (two shipped bugs came from a catch-all that swallowed everything).
724 */
725export type CodexChildNotificationRoute = "agent-event" | "parent" | "drop";
726
727const CHILD_AGENT_EVENT_METHODS: ReadonlySet<string> = new Set([
728 "turn/started",
729 "turn/completed",
730 "thread/status/changed",
731 "thread/tokenUsage/updated",
732 "item/started",
733 "item/completed",
734 "thread/closed",
735 "error",
736]);
737
738const CHILD_CHATTER_METHODS: ReadonlySet<string> = new Set([
739 "item/agentMessage/delta",
740 "item/reasoning/textDelta",
741 "item/reasoning/summaryTextDelta",
742 "item/reasoning/summaryPartAdded",
743 "item/commandExecution/outputDelta",
744 "item/fileChange/outputDelta",
745 "item/fileChange/patchUpdated",
746 "item/plan/delta",
747 "turn/plan/updated",
748 "turn/diff/updated",
749 "thread/name/updated",
750 "thread/settings/updated",
751 "rawResponseItem/completed",
752 // Child-owned thread lifecycle: the parent adapter maps these onto the
753 // PARENT thread (archived/compacted state), so a child compacting would
754 // rewrite the parent. Mirrors the v1 suppressor list — dropping them is
755 // the pre-existing behavior for collab children (review finding).
756 "thread/archived",
757 "thread/unarchived",
758 "thread/compacted",
759 // Registration path 1 handles a child's first thread/started; a repeat
760 // must not reach the parent (it would restart the parent's thread state).
761 "thread/started",
762]);
763
764export function routeCodexChildNotification(method: string): CodexChildNotificationRoute {
765 if (CHILD_AGENT_EVENT_METHODS.has(method)) {
766 return "agent-event";
767 }
768 if (CHILD_CHATTER_METHODS.has(method)) {
769 return "drop";
770 }
771 // Unknown or parent-owned (serverRequest/resolved, approvals, …).
772 return "parent";
773}
774
644function toCodexUserInputAnswer(775function toCodexUserInputAnswer(
645 questionId: string,776 questionId: string,
646 value: ProviderUserInputAnswers[string],777 value: ProviderUserInputAnswers[string],
...@@ -723,6 +854,9 @@ export const makeCodexSessionRuntime = (...@@ -723,6 +854,9 @@ export const makeCodexSessionRuntime = (
723 const approvalCorrelationsRef = yield* Ref.make(new Map<string, ApprovalCorrelation>());854 const approvalCorrelationsRef = yield* Ref.make(new Map<string, ApprovalCorrelation>());
724 const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>());855 const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>());
725 const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>());856 const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>());
857 const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>());
858 /** Child provider-thread id → its currently running provider turn id. */
859 const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>());
726 const closedRef = yield* Ref.make(false);860 const closedRef = yield* Ref.make(false);
727861
728 // `~` is not shell-expanded when env vars are set via862 // `~` is not shell-expanded when env vars are set via
...@@ -839,6 +973,280 @@ export const makeCodexSessionRuntime = (...@@ -839,6 +973,280 @@ export const makeCodexSessionRuntime = (
839 ),973 ),
840 );974 );
841975
976 /**
977 * Registers v2 collab children and re-emits their notifications as
978 * synthetic `collabAgent/*` events for the adapter's task.* synthesis.
979 * Returns true when the notification was fully handled (must not reach
980 * parent-timeline mapping).
981 */
982 const interceptCollabChildNotification = (notification: CodexServerNotification) =>
983 Effect.gen(function* () {
984 // Registration path 1: child thread announces itself with a
985 // subAgent thread_spawn source.
986 if (notification.method === "thread/started") {
987 const thread = notification.params.thread;
988 const spawn = readThreadSpawnSource(thread);
989 if (!spawn) {
990 return false;
991 }
992 // Merge with any subAgentActivity registration that got here
993 // first. spawnTurnId is REGISTRATION-time-only on both paths: for
994 // an already-known child we keep its value (set or unset) — a
995 // later thread/started during an unrelated parent turn must not
996 // backfill that turn as the spawn batch, which would stamp an old
997 // child onto a new fleet's CTA (review finding). Only a genuinely
998 // new registration captures the current turn.
999 const existingChild = (yield* Ref.get(collabChildAgentsRef)).get(thread.id);
1000 const spawnTurnId = existingChild
1001 ? existingChild.spawnTurnId
1002 : ((yield* Ref.get(sessionRef)).activeTurnId ?? undefined);
1003 const state: CollabChildAgentState = {
1004 agentThreadId: thread.id,
1005 nickname: spawn.nickname ?? thread.agentNickname ?? existingChild?.nickname,
1006 role: spawn.role ?? thread.agentRole ?? existingChild?.role,
1007 agentPath: spawn.agentPath ?? existingChild?.agentPath,
1008 depth: spawn.depth ?? existingChild?.depth,
1009 parentThreadId:
1010 spawn.parentThreadId ?? thread.parentThreadId ?? existingChild?.parentThreadId,
1011 spawnTurnId,
1012 };
1013 yield* Ref.update(collabChildAgentsRef, (current) => {
1014 const next = new Map(current);
1015 next.set(thread.id, state);
1016 return next;
1017 });
1018 yield* emitEvent({
1019 kind: "notification",
1020 threadId: options.threadId,
1021 method: "collabAgent/started",
1022 ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}),
1023 payload: {
1024 agentThreadId: state.agentThreadId,
1025 ...(state.nickname ? { nickname: state.nickname } : {}),
1026 ...(state.role ? { role: state.role } : {}),
1027 ...(state.agentPath ? { agentPath: state.agentPath } : {}),
1028 ...(state.depth !== undefined ? { depth: state.depth } : {}),
1029 ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}),
1030 },
1031 });
1032 return true;
1033 }
1034
1035 // Registration path 2: parent-side subAgentActivity item names the
1036 // child thread (may arrive before or after thread/started).
1037 if (
1038 (notification.method === "item/started" || notification.method === "item/completed") &&
1039 notification.params.item.type === "subAgentActivity"
1040 ) {
1041 const item = notification.params.item;
1042 // Never register the session's ROOT thread as its own child. The
1043 // wire emits subAgentActivity {agentPath: "/root", interacted}
1044 // about the root during collab runs; registering it intercepted
1045 // every subsequent root notification — including the final
1046 // assistant message and turn/completed — so the thread hung
1047 // "working" after all subagents finished (live-probe finding).
1048 const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef));
1049 if (
1050 item.agentThreadId === rootProviderThreadId ||
1051 item.agentPath === "/root" ||
1052 item.agentPath === "/"
1053 ) {
1054 return false;
1055 }
1056 const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined;
1057 yield* Ref.update(collabChildAgentsRef, (current) => {
1058 const existing = current.get(item.agentThreadId);
1059 const next = new Map(current);
1060 // Merge-late semantics: when thread/started registered first, a
1061 // later subAgentActivity still carries the real agentPath (and a
1062 // derived nickname) — fill missing fields, never clobber known
1063 // ones. spawnTurnId is registration-time-only: for an already
1064 // registered child, a later activity during an UNRELATED turn
1065 // must not backfill that turn as the spawn batch (review
1066 // finding); an unset spawn turn stays unset.
1067 next.set(item.agentThreadId, {
1068 agentThreadId: item.agentThreadId,
1069 nickname:
1070 existing?.nickname ??
1071 item.agentPath.split("/").findLast((segment) => segment.length > 0),
1072 role: existing?.role,
1073 agentPath: existing?.agentPath ?? item.agentPath,
1074 depth: existing?.depth,
1075 parentThreadId: existing?.parentThreadId,
1076 spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId,
1077 });
1078 return next;
1079 });
1080 const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId);
1081 yield* emitEvent({
1082 kind: "notification",
1083 threadId: options.threadId,
1084 method: "collabAgent/activity",
1085 ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}),
1086 payload: {
1087 agentThreadId: item.agentThreadId,
1088 agentPath: item.agentPath,
1089 activityKind: item.kind,
1090 },
1091 });
1092 return true;
1093 }
1094
1095 // Interception: notifications addressed to a registered child thread
1096 // become agent-scoped synthetic events instead of parent chatter.
1097 const providerConversationId = readNotificationThreadId(notification);
1098 if (!providerConversationId) {
1099 return false;
1100 }
1101 // Belt-and-braces: the root thread's traffic must never be
1102 // intercepted, whatever the registry says.
1103 const interceptRootId = currentProviderThreadId(yield* Ref.get(sessionRef));
1104 if (providerConversationId === interceptRootId) {
1105 return false;
1106 }
1107 const children = yield* Ref.get(collabChildAgentsRef);
1108 const child = children.get(providerConversationId);
1109 if (!child) {
1110 return false;
1111 }
1112 const childIdentity = {
1113 agentThreadId: child.agentThreadId,
1114 ...(child.nickname ? { nickname: child.nickname } : {}),
1115 ...(child.role ? { role: child.role } : {}),
1116 ...(child.agentPath ? { agentPath: child.agentPath } : {}),
1117 };
1118 switch (notification.method) {
1119 case "turn/started": {
1120 const childTurnId =
1121 typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string"
1122 ? ((notification.params as { turn: { id: string } }).turn.id as string)
1123 : undefined;
1124 if (childTurnId) {
1125 yield* Ref.update(collabChildLiveTurnsRef, (current) => {
1126 const next = new Map(current);
1127 next.set(child.agentThreadId, childTurnId);
1128 return next;
1129 });
1130 }
1131 yield* emitEvent({
1132 kind: "notification",
1133 threadId: options.threadId,
1134 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1135 method: "collabAgent/turnStarted",
1136 payload: childIdentity,
1137 });
1138 return true;
1139 }
1140 case "turn/completed":
1141 yield* Ref.update(collabChildLiveTurnsRef, (current) => {
1142 const next = new Map(current);
1143 next.delete(child.agentThreadId);
1144 return next;
1145 });
1146 yield* emitEvent({
1147 kind: "notification",
1148 threadId: options.threadId,
1149 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1150 method: "collabAgent/turnCompleted",
1151 payload: {
1152 ...childIdentity,
1153 turn: notification.params.turn,
1154 },
1155 });
1156 return true;
1157 case "thread/status/changed":
1158 yield* emitEvent({
1159 kind: "notification",
1160 threadId: options.threadId,
1161 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1162 method: "collabAgent/statusChanged",
1163 payload: {
1164 ...childIdentity,
1165 status: notification.params.status,
1166 },
1167 });
1168 return true;
1169 case "thread/tokenUsage/updated":
1170 yield* emitEvent({
1171 kind: "notification",
1172 threadId: options.threadId,
1173 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1174 method: "collabAgent/tokenUsage",
1175 payload: {
1176 ...childIdentity,
1177 tokenUsage: notification.params.tokenUsage,
1178 },
1179 });
1180 return true;
1181 case "item/started":
1182 case "item/completed":
1183 yield* emitEvent({
1184 kind: "notification",
1185 threadId: options.threadId,
1186 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1187 method: "collabAgent/item",
1188 payload: {
1189 ...childIdentity,
1190 item: notification.params.item,
1191 },
1192 });
1193 return true;
1194 case "thread/closed":
1195 // The child is gone: drop its live-turn entry so a later Stop
1196 // doesn't waste a turn/interrupt RPC on a closed thread before
1197 // reaching the parent (review finding).
1198 yield* Ref.update(collabChildLiveTurnsRef, (current) => {
1199 const next = new Map(current);
1200 next.delete(child.agentThreadId);
1201 return next;
1202 });
1203 yield* emitEvent({
1204 kind: "notification",
1205 threadId: options.threadId,
1206 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1207 method: "collabAgent/closed",
1208 payload: childIdentity,
1209 });
1210 return true;
1211 case "error": {
1212 // A child error must surface as a failed agent, not vanish into
1213 // the default swallow (review finding: the child stayed
1214 // "running" forever). Retryable errors (willRetry) keep the
1215 // child RUNNING and interruptible — mirroring the root error
1216 // handler; settling it would orphan a still-live child from
1217 // Stop (review finding). Terminal errors clean up the live turn
1218 // like thread/closed and reuse the statusChanged systemError
1219 // path.
1220 const willRetry = (notification.params as { willRetry?: boolean }).willRetry === true;
1221 if (willRetry) {
1222 return true;
1223 }
1224 yield* Ref.update(collabChildLiveTurnsRef, (current) => {
1225 const next = new Map(current);
1226 next.delete(child.agentThreadId);
1227 return next;
1228 });
1229 yield* emitEvent({
1230 kind: "notification",
1231 threadId: options.threadId,
1232 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1233 method: "collabAgent/statusChanged",
1234 payload: {
1235 ...childIdentity,
1236 status: { type: "systemError" },
1237 },
1238 });
1239 return true;
1240 }
1241 default:
1242 // Routing table decides (single source of truth, asserted
1243 // against captured wire traces): enumerated chatter is dropped,
1244 // everything else — including methods this build has never seen
1245 // — falls through to the parent path rather than vanishing.
1246 return routeCodexChildNotification(notification.method) === "drop";
1247 }
1248 });
1249
842 const handleRawNotification = (notification: CodexServerNotification) =>1250 const handleRawNotification = (notification: CodexServerNotification) =>
843 Effect.gen(function* () {1251 Effect.gen(function* () {
844 const payload = notification.params;1252 const payload = notification.params;
...@@ -852,7 +1260,68 @@ export const makeCodexSessionRuntime = (...@@ -852,7 +1260,68 @@ export const makeCodexSessionRuntime = (
852 })();1260 })();
8531261
854 rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId);1262 rememberCollabReceiverTurns(collabReceiverTurns, notification, route.turnId);
855 if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) {1263 // Interception FIRST: a registered v2 child is usually also in the
1264 // receiver-turn map (collabAgentToolCall.receiverThreadIds), and the
1265 // legacy suppressor below would drop its lifecycle before it could
1266 // become synthetic collabAgent events (review finding). The
1267 // suppressor still covers UNREGISTERED children.
1268 if (yield* interceptCollabChildNotification(notification)) {
1269 yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns);
1270 return;
1271 }
1272
1273 // Suppression applies to receiver-map children (v1) AND to any
1274 // conversation that is not the root thread. The live capture
1275 // (codexMultiAgentWire.json) shows a child's thread/status/changed
1276 // arriving BEFORE anything registers the child — pre-registration
1277 // lifecycle must not reach the parent path, where the adapter maps
1278 // thread/* onto parent session state. Root-id-known guard keeps the
1279 // root's own early notifications flowing during session open.
1280 const suppressRootId = currentProviderThreadId(yield* Ref.get(sessionRef));
1281 const foreignConversation = (() => {
1282 const providerConversationId = readNotificationThreadId(notification);
1283 return (
1284 providerConversationId !== undefined &&
1285 suppressRootId !== undefined &&
1286 providerConversationId !== suppressRootId
1287 );
1288 })();
1289 if (
1290 (childParentTurnId !== undefined || foreignConversation) &&
1291 shouldSuppressChildConversationNotification(notification.method)
1292 ) {
1293 // Stop-everything must not depend on registration timing: a
1294 // child's turn/started can arrive before the subAgentActivity that
1295 // registers it (captured ordering), and suppressing it without
1296 // remembering the live turn would leave that child running after
1297 // Stop (review finding). Track live turns for ANY foreign
1298 // conversation; interrupts are best-effort per child, so a
1299 // false-positive entry costs one ignored RPC at worst.
1300 const foreignThreadId = readNotificationThreadId(notification);
1301 if (foreignThreadId !== undefined) {
1302 if (notification.method === "turn/started") {
1303 const foreignTurnId =
1304 typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string"
1305 ? (notification.params as { turn: { id: string } }).turn.id
1306 : undefined;
1307 if (foreignTurnId) {
1308 yield* Ref.update(collabChildLiveTurnsRef, (current) => {
1309 const next = new Map(current);
1310 next.set(foreignThreadId, foreignTurnId);
1311 return next;
1312 });
1313 }
1314 } else if (
1315 notification.method === "turn/completed" ||
1316 notification.method === "thread/closed"
1317 ) {
1318 yield* Ref.update(collabChildLiveTurnsRef, (current) => {
1319 const next = new Map(current);
1320 next.delete(foreignThreadId);
1321 return next;
1322 });
1323 }
1324 }
856 yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns);1325 yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns);
857 return;1326 return;
858 }1327 }
...@@ -1331,6 +1800,26 @@ export const makeCodexSessionRuntime = (...@@ -1331,6 +1800,26 @@ export const makeCodexSessionRuntime = (
1331 Effect.gen(function* () {1800 Effect.gen(function* () {
1332 const providerThreadId = yield* readProviderThreadId;1801 const providerThreadId = yield* readProviderThreadId;
1333 const session = yield* Ref.get(sessionRef);1802 const session = yield* Ref.get(sessionRef);
1803 // Stop-everything: children are full threads with their own turns;
1804 // interrupting only the parent leaves the fleet running. Interrupt
1805 // each live child turn first, best-effort per child, BOUNDED: the
1806 // transport awaits an unbounded Deferred per request, so a wedged
1807 // child would otherwise block the parent interrupt forever —
1808 // exactly during the runaway fleet where Stop matters most
1809 // (review finding). Per-child and overall deadlines guarantee the
1810 // parent interrupt below always runs.
1811 const liveChildTurns = yield* Ref.get(collabChildLiveTurnsRef);
1812 yield* Effect.forEach(
1813 Array.from(liveChildTurns.entries()),
1814 ([childThreadId, childTurnId]) =>
1815 client
1816 .request("turn/interrupt", {
1817 threadId: childThreadId,
1818 turnId: childTurnId,
1819 })
1820 .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore),
1821 { concurrency: 8, discard: true },
1822 ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore);
1334 const effectiveTurnId = turnId ?? session.activeTurnId;1823 const effectiveTurnId = turnId ?? session.activeTurnId;
1335 if (!effectiveTurnId) {1824 if (!effectiveTurnId) {
1336 return;1825 return;
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs created+93
...@@ -0,0 +1,93 @@
1// Minimal codex app-server stand-in for runtime-level collab tests.
2// Speaks just enough of the protocol for CodexSessionRuntime to start a
3// session, using REAL captured responses (codexMultiAgentWire.json), then
4// replays a scripted multi-agent notification sequence read from the
5// T3_CODEX_COLLAB_SCRIPT env var (a JSON file path) when the first turn
6// starts. Runs as a plain Node process — stdlib only.
7import * as NodeFS from "node:fs";
8import * as NodeReadline from "node:readline";
9import * as NodePath from "node:path";
10import * as NodeURL from "node:url";
11
12const here = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
13const fixture = JSON.parse(
14 NodeFS.readFileSync(NodePath.join(here, "codexMultiAgentWire.json"), "utf8"),
15);
16const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8"));
17
18const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
19
20const rl = NodeReadline.createInterface({ input: process.stdin });
21rl.on("line", (line) => {
22 let message;
23 try {
24 message = JSON.parse(line);
25 } catch {
26 return;
27 }
28 const { id, method } = message;
29 if (method === "initialize") {
30 write({
31 id,
32 result: {
33 userAgent: "t3-collab-mock/0.0.0",
34 codexHome: "/tmp",
35 platformFamily: "unix",
36 platformOs: "linux",
37 },
38 });
39 return;
40 }
41 if (method === "thread/start" || method === "thread/resume") {
42 write({ id, result: fixture.responses.threadStart });
43 return;
44 }
45 if (method === "turn/start") {
46 write({ id, result: fixture.responses.turnStart });
47 const rootThreadId = script.rootThreadId;
48 const turn = fixture.responses.turnStart.turn;
49 write({
50 jsonrpc: "2.0",
51 method: "turn/started",
52 params: { threadId: rootThreadId, turn },
53 });
54 for (const notification of script.notifications) {
55 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });
56 }
57 if (script.holdTurnOpen !== true) {
58 write({
59 jsonrpc: "2.0",
60 method: "turn/completed",
61 params: {
62 threadId: rootThreadId,
63 turn: { ...turn, status: "completed" },
64 },
65 });
66 }
67 return;
68 }
69 if (method === "turn/interrupt") {
70 // Record which thread/turn was interrupted (append-only sidecar file the
71 // test reads) so Stop coverage can assert every live child was reached.
72 // failInterruptFor simulates a dead child whose interrupt errors.
73 const target = message.params?.threadId;
74 NodeFS.appendFileSync(
75 `${process.env.T3_CODEX_COLLAB_SCRIPT}.interrupts`,
76 `${JSON.stringify({ threadId: target, turnId: message.params?.turnId })}\n`,
77 );
78 if (script.failInterruptFor && script.failInterruptFor === target) {
79 write({ id, error: { code: -32000, message: "thread already closed" } });
80 return;
81 }
82 if (script.hangInterruptFor && script.hangInterruptFor === target) {
83 // Never respond: simulates a wedged child whose RPC neither resolves
84 // nor rejects. The runtime's bounded deadline must move on.
85 return;
86 }
87 write({ id, result: {} });
88 return;
89 }
90 if (id !== undefined) {
91 write({ id, result: {} });
92 }
93});
apps/server/src/provider/testFixtures/codexCollabMockPeer.sh created+6
...@@ -0,0 +1,6 @@
1#!/usr/bin/env bash
2# Wrapper so CodexSessionRuntime can spawn the mock peer: the runtime always
3# passes "app-server" as the first argument (real codex CLI subcommand);
4# discard it and exec node on the .mjs peer.
5shift
6exec node "$(dirname "$0")/codexCollabMockPeer.mjs" "$@"
apps/server/src/provider/testFixtures/codexMultiAgentWire.json created+445
...@@ -0,0 +1,445 @@
1{
2 "capturedWith": {
3 "cli": "codex-cli 0.145.0",
4 "model": "gpt-5.6-luna",
5 "effort": "low"
6 },
7 "rootThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
8 "childThreadIds": [
9 "019fcfd6-2883-77e0-9013-4410ede70371",
10 "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf"
11 ],
12 "notifications": [
13 {
14 "method": "thread/started",
15 "params": {
16 "thread": {
17 "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
18 "extra": null,
19 "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
20 "forkedFromId": null,
21 "parentThreadId": null,
22 "preview": "",
23 "ephemeral": false,
24 "historyMode": "legacy",
25 "modelProvider": "openai",
26 "createdAt": 1785898342,
27 "updatedAt": 1785898342,
28 "recencyAt": 1785898342,
29 "status": {
30 "type": "idle"
31 },
32 "path": "/fixtures/codex/rollout.jsonl",
33 "cwd": "/workspace/repo",
34 "cliVersion": "0.145.0",
35 "source": "vscode",
36 "canAcceptDirectInput": true,
37 "threadSource": null,
38 "agentNickname": null,
39 "agentRole": null,
40 "gitInfo": null,
41 "name": null,
42 "turns": []
43 }
44 }
45 },
46 {
47 "method": "thread/status/changed",
48 "params": {
49 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
50 "status": {
51 "type": "active",
52 "activeFlags": []
53 }
54 }
55 },
56 {
57 "method": "turn/started",
58 "params": {
59 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
60 "turn": {
61 "id": "019fcfd6-1806-7de1-8564-de69fd55bffb",
62 "items": [],
63 "itemsView": "notLoaded",
64 "status": "inProgress",
65 "error": null,
66 "startedAt": 1785898342,
67 "completedAt": null,
68 "durationMs": null
69 }
70 }
71 },
72 {
73 "method": "thread/status/changed",
74 "params": {
75 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
76 "status": {
77 "type": "idle"
78 }
79 }
80 },
81 {
82 "method": "item/completed",
83 "params": {
84 "item": {
85 "type": "subAgentActivity",
86 "id": "call_S2JPiq0sMnJYS7kswVdvzEjB",
87 "kind": "started",
88 "agentThreadId": "019fcfd6-2883-77e0-9013-4410ede70371",
89 "agentPath": "/root/alpha"
90 },
91 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
92 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
93 "completedAtMs": 1785898346687
94 }
95 },
96 {
97 "method": "thread/tokenUsage/updated",
98 "params": {
99 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
100 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
101 "tokenUsage": {
102 "total": {
103 "totalTokens": 18261,
104 "inputTokens": 18228,
105 "cachedInputTokens": 11008,
106 "cacheWriteInputTokens": 0,
107 "outputTokens": 33,
108 "reasoningOutputTokens": 0
109 },
110 "last": {
111 "totalTokens": 18261,
112 "inputTokens": 18228,
113 "cachedInputTokens": 11008,
114 "cacheWriteInputTokens": 0,
115 "outputTokens": 33,
116 "reasoningOutputTokens": 0
117 },
118 "modelContextWindow": 258400
119 }
120 }
121 },
122 {
123 "method": "thread/status/changed",
124 "params": {
125 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
126 "status": {
127 "type": "active",
128 "activeFlags": []
129 }
130 }
131 },
132 {
133 "method": "turn/started",
134 "params": {
135 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
136 "turn": {
137 "id": "019fcfd6-28bf-7e00-a873-4554526dc845",
138 "items": [],
139 "itemsView": "notLoaded",
140 "status": "inProgress",
141 "error": null,
142 "startedAt": 1785898346,
143 "completedAt": null,
144 "durationMs": null
145 }
146 }
147 },
148 {
149 "method": "thread/status/changed",
150 "params": {
151 "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf",
152 "status": {
153 "type": "idle"
154 }
155 }
156 },
157 {
158 "method": "item/completed",
159 "params": {
160 "item": {
161 "type": "subAgentActivity",
162 "id": "call_A2U7sfjmdGlUn5bn6gUNI0rl",
163 "kind": "started",
164 "agentThreadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf",
165 "agentPath": "/root/beta"
166 },
167 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
168 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
169 "completedAtMs": 1785898348329
170 }
171 },
172 {
173 "method": "thread/status/changed",
174 "params": {
175 "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf",
176 "status": {
177 "type": "active",
178 "activeFlags": []
179 }
180 }
181 },
182 {
183 "method": "turn/started",
184 "params": {
185 "threadId": "019fcfd6-2ef7-7932-9ff5-dea6ad6f4daf",
186 "turn": {
187 "id": "019fcfd6-2f29-79e3-aa6a-c5836a519d3f",
188 "items": [],
189 "itemsView": "notLoaded",
190 "status": "inProgress",
191 "error": null,
192 "startedAt": 1785898348,
193 "completedAt": null,
194 "durationMs": null
195 }
196 }
197 },
198 {
199 "method": "thread/tokenUsage/updated",
200 "params": {
201 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
202 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
203 "tokenUsage": {
204 "total": {
205 "totalTokens": 36576,
206 "inputTokens": 36510,
207 "cachedInputTokens": 28160,
208 "cacheWriteInputTokens": 0,
209 "outputTokens": 66,
210 "reasoningOutputTokens": 0
211 },
212 "last": {
213 "totalTokens": 18315,
214 "inputTokens": 18282,
215 "cachedInputTokens": 17152,
216 "cacheWriteInputTokens": 0,
217 "outputTokens": 33,
218 "reasoningOutputTokens": 0
219 },
220 "modelContextWindow": 258400
221 }
222 }
223 },
224 {
225 "method": "item/completed",
226 "params": {
227 "item": {
228 "type": "subAgentActivity",
229 "id": "call_2W1NboHClVo0VqAf1y3ZFinO",
230 "kind": "interacted",
231 "agentThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
232 "agentPath": "/root"
233 },
234 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
235 "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845",
236 "completedAtMs": 1785898349265
237 }
238 },
239 {
240 "method": "thread/tokenUsage/updated",
241 "params": {
242 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
243 "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845",
244 "tokenUsage": {
245 "total": {
246 "totalTokens": 20756,
247 "inputTokens": 20713,
248 "cachedInputTokens": 5888,
249 "cacheWriteInputTokens": 0,
250 "outputTokens": 43,
251 "reasoningOutputTokens": 11
252 },
253 "last": {
254 "totalTokens": 20756,
255 "inputTokens": 20713,
256 "cachedInputTokens": 5888,
257 "cacheWriteInputTokens": 0,
258 "outputTokens": 43,
259 "reasoningOutputTokens": 11
260 },
261 "modelContextWindow": 258400
262 }
263 }
264 },
265 {
266 "method": "item/started",
267 "params": {
268 "item": {
269 "type": "collabAgentToolCall",
270 "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz",
271 "tool": "wait",
272 "status": "inProgress",
273 "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
274 "receiverThreadIds": [],
275 "prompt": null,
276 "model": null,
277 "reasoningEffort": null,
278 "agentsStates": {}
279 },
280 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
281 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
282 "startedAtMs": 1785898349931
283 }
284 },
285 {
286 "method": "item/completed",
287 "params": {
288 "item": {
289 "type": "collabAgentToolCall",
290 "id": "call_XGMDYMm4O8ne7vg3Y6p4eSqz",
291 "tool": "wait",
292 "status": "completed",
293 "senderThreadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
294 "receiverThreadIds": [],
295 "prompt": null,
296 "model": null,
297 "reasoningEffort": null,
298 "agentsStates": {}
299 },
300 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
301 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
302 "completedAtMs": 1785898349931
303 }
304 },
305 {
306 "method": "thread/tokenUsage/updated",
307 "params": {
308 "threadId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
309 "turnId": "019fcfd6-1806-7de1-8564-de69fd55bffb",
310 "tokenUsage": {
311 "total": {
312 "totalTokens": 54933,
313 "inputTokens": 54846,
314 "cachedInputTokens": 45312,
315 "cacheWriteInputTokens": 0,
316 "outputTokens": 87,
317 "reasoningOutputTokens": 0
318 },
319 "last": {
320 "totalTokens": 18357,
321 "inputTokens": 18336,
322 "cachedInputTokens": 17152,
323 "cacheWriteInputTokens": 0,
324 "outputTokens": 21,
325 "reasoningOutputTokens": 0
326 },
327 "modelContextWindow": 258400
328 }
329 }
330 },
331 {
332 "method": "thread/tokenUsage/updated",
333 "params": {
334 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
335 "turnId": "019fcfd6-28bf-7e00-a873-4554526dc845",
336 "tokenUsage": {
337 "total": {
338 "totalTokens": 41529,
339 "inputTokens": 41481,
340 "cachedInputTokens": 26112,
341 "cacheWriteInputTokens": 0,
342 "outputTokens": 48,
343 "reasoningOutputTokens": 11
344 },
345 "last": {
346 "totalTokens": 20773,
347 "inputTokens": 20768,
348 "cachedInputTokens": 20224,
349 "cacheWriteInputTokens": 0,
350 "outputTokens": 5,
351 "reasoningOutputTokens": 0
352 },
353 "modelContextWindow": 258400
354 }
355 }
356 },
357 {
358 "method": "thread/status/changed",
359 "params": {
360 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
361 "status": {
362 "type": "idle"
363 }
364 }
365 },
366 {
367 "method": "turn/completed",
368 "params": {
369 "threadId": "019fcfd6-2883-77e0-9013-4410ede70371",
370 "turn": {
371 "id": "019fcfd6-28bf-7e00-a873-4554526dc845",
372 "items": [],
373 "itemsView": "notLoaded",
374 "status": "completed",
375 "error": null,
376 "startedAt": 1785898346,
377 "completedAt": 1785898350,
378 "durationMs": 3792
379 }
380 }
381 }
382 ],
383 "responses": {
384 "threadStart": {
385 "thread": {
386 "id": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
387 "extra": null,
388 "sessionId": "019fcfd6-17bb-72f0-ae12-a1f2dee6e3e5",
389 "forkedFromId": null,
390 "parentThreadId": null,
391 "preview": "",
392 "ephemeral": false,
393 "historyMode": "legacy",
394 "modelProvider": "openai",
395 "createdAt": 1785898342,
396 "updatedAt": 1785898342,
397 "recencyAt": 1785898342,
398 "status": {
399 "type": "idle"
400 },
401 "path": "/fixtures/codex/rollout.jsonl",
402 "cwd": "/workspace/repo",
403 "cliVersion": "0.145.0",
404 "source": "vscode",
405 "canAcceptDirectInput": true,
406 "threadSource": null,
407 "agentNickname": null,
408 "agentRole": null,
409 "gitInfo": null,
410 "name": null,
411 "turns": []
412 },
413 "model": "gpt-5.6-sol",
414 "modelProvider": "openai",
415 "serviceTier": "default",
416 "cwd": "/workspace/repo",
417 "runtimeWorkspaceRoots": ["/workspace/repo"],
418 "instructionSources": ["/fixtures/scrubbed", "/workspace/repo/AGENTS.md"],
419 "approvalPolicy": "on-request",
420 "approvalsReviewer": "auto_review",
421 "sandbox": {
422 "type": "workspaceWrite",
423 "writableRoots": [],
424 "networkAccess": false,
425 "excludeTmpdirEnvVar": false,
426 "excludeSlashTmp": false
427 },
428 "activePermissionProfile": null,
429 "reasoningEffort": "high",
430 "multiAgentMode": "explicitRequestOnly"
431 },
432 "turnStart": {
433 "turn": {
434 "id": "019fcfd6-1806-7de1-8564-de69fd55bffb",
435 "items": [],
436 "itemsView": "notLoaded",
437 "status": "inProgress",
438 "error": null,
439 "startedAt": null,
440 "completedAt": null,
441 "durationMs": null
442 }
443 }
444 }
445}
apps/server/src/ws.ts+7
...@@ -91,6 +91,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts";...@@ -91,6 +91,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts";
91import * as PortScanner from "./preview/PortScanner.ts";91import * as PortScanner from "./preview/PortScanner.ts";
92import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";92import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
93import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";93import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
94import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts";
94import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";95import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
95import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";96import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
96import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";97import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
...@@ -1084,6 +1085,12 @@ const makeWsRpcLayer = (...@@ -1084,6 +1085,12 @@ const makeWsRpcLayer = (
1084 ),1085 ),
1085 { "rpc.aggregate": "orchestration" },1086 { "rpc.aggregate": "orchestration" },
1086 ),1087 ),
1088 [ORCHESTRATION_WS_METHODS.getWorkflowScript]: (input) =>
1089 observeRpcEffect(
1090 ORCHESTRATION_WS_METHODS.getWorkflowScript,
1091 readWorkflowScript({ scriptPath: input.scriptPath }),
1092 { "rpc.aggregate": "orchestration" },
1093 ),
1087 [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>1094 [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
1088 observeRpcEffect(1095 observeRpcEffect(
1089 ORCHESTRATION_WS_METHODS.getTurnDiff,1096 ORCHESTRATION_WS_METHODS.getTurnDiff,
apps/web/src/components/AgentsPanel.tsx created+568
...@@ -0,0 +1,568 @@
1/**
2 * Agents right-panel surface: the fleet view over the native subagent fold,
3 * and the ONLY place the roster renders (the chat carries one CTA row per
4 * spawn batch).
5 *
6 * Visualization rules (from live-test feedback):
7 * - Live work first: running workflows and direct spawns sort above settled.
8 * - Rows are flat status lines — no expansion, no per-agent tool feeds. The
9 * row answers "who / what phase / how much"; anything deeper is a future
10 * drill-in, not an unfold.
11 * - A settled workflow run collapses to a single summary line; click it to
12 * show its member list inline (the one allowed toggle — run granularity,
13 * not agent granularity).
14 * - Static status dots, DOM-write elapsed timers, plain token counters.
15 */
16import { useAtomValue } from "@effect/atom-react";
17import type {
18 AgentPanelModel,
19 AgentPanelWorkflowGroup,
20 RuntimeSubagent,
21} from "@t3tools/client-runtime/state/subagentRuntime";
22import {
23 formatSubagentModelLabel,
24 formatSubagentTokenCount,
25} from "@t3tools/client-runtime/state/subagentRuntime";
26import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
27import { Bot, Braces, Check, ChevronDown, ChevronRight, X } from "lucide-react";
28import { useEffect, useRef, useState } from "react";
29
30import { cn } from "~/lib/utils";
31import { orchestrationEnvironment } from "~/state/orchestration";
32import { ScrollArea } from "~/components/ui/scroll-area";
33
34/**
35 * In-flight states all present as Working (one steady state, per the
36 * monitoring-pill design: detail belongs in the activity sub-line, and a
37 * stalled/waiting/queued subagent is still the fleet doing its job, not a
38 * user problem). Only settled states differentiate.
39 */
40const STATUS_VISUALS: Record<RuntimeSubagent["status"], { dotClass: string; label: string }> = {
41 pending: { dotClass: "bg-info", label: "Working" },
42 running: { dotClass: "bg-info", label: "Working" },
43 waiting: { dotClass: "bg-info", label: "Working" },
44 // Idle reads as settled (muted, not sky): a resting Codex child looks done
45 // unless resumed — live-test: sky idle dots read as stuck in-progress.
46 idle: { dotClass: "bg-muted-foreground/50", label: "Idle · resumable" },
47 completed: { dotClass: "bg-success", label: "Completed" },
48 failed: { dotClass: "bg-destructive", label: "Failed" },
49 cancelled: { dotClass: "bg-muted-foreground/60", label: "Stopped" },
50 interrupted: { dotClass: "bg-muted-foreground/60", label: "Stopped" },
51};
52
53function StatusDot({ status }: { status: RuntimeSubagent["status"] }) {
54 return (
55 <span
56 aria-hidden
57 className={cn("size-1.5 shrink-0 rounded-full", STATUS_VISUALS[status].dotClass)}
58 />
59 );
60}
61
62function formatElapsedSeconds(totalSeconds: number): string {
63 const seconds = Math.max(0, Math.floor(totalSeconds));
64 const minutes = Math.floor(seconds / 60);
65 if (minutes === 0) {
66 return `${seconds}s`;
67 }
68 const hours = Math.floor(minutes / 60);
69 if (hours === 0) {
70 return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
71 }
72 return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
73}
74
75function elapsedBetween(startedAt: string, endIso: string | null): string {
76 const start = Date.parse(startedAt);
77 const end = endIso ? Date.parse(endIso) : Date.now();
78 if (Number.isNaN(start) || Number.isNaN(end)) {
79 return "";
80 }
81 return formatElapsedSeconds((end - start) / 1000);
82}
83
84/**
85 * Elapsed time for the current activation. Live agents self-tick via DOM
86 * writes (zero React commits per tick); settled agents freeze at completedAt.
87 */
88function AgentElapsed({ agent }: { agent: RuntimeSubagent }) {
89 const textRef = useRef<HTMLSpanElement>(null);
90 const live = agent.status === "running" || agent.status === "waiting";
91 const startedAt = agent.startedAt;
92
93 useEffect(() => {
94 if (!live || !startedAt) {
95 return;
96 }
97 const update = () => {
98 if (textRef.current) {
99 textRef.current.textContent = elapsedBetween(startedAt, null);
100 }
101 };
102 update();
103 const id = setInterval(update, 1000);
104 return () => clearInterval(id);
105 }, [live, startedAt]);
106
107 if (!startedAt) {
108 return null;
109 }
110 return (
111 <span ref={textRef} className="tabular-nums">
112 {elapsedBetween(startedAt, live ? null : agent.completedAt)}
113 </span>
114 );
115}
116
117/**
118 * Status-dependent activity line. Live rows lead with what is happening now;
119 * settled rows lead with the outcome. Errors are the only inline previews on
120 * failed rows because they explain a red row at a glance.
121 */
122function agentActivityText(agent: RuntimeSubagent): string | null {
123 const live =
124 agent.status === "running" || agent.status === "pending" || agent.status === "waiting";
125 if (live) {
126 return (
127 agent.progress ??
128 (agent.lastToolName ? `▸ ${agent.lastToolName}` : null) ??
129 agent.result ??
130 agent.error
131 );
132 }
133 return (
134 agent.error ??
135 agent.result ??
136 agent.progress ??
137 (agent.lastToolName ? `▸ ${agent.lastToolName}` : null)
138 );
139}
140
141/** Flat, non-interactive agent status line. No unfold. */
142function AgentRow({ agent }: { agent: RuntimeSubagent }) {
143 const visuals = STATUS_VISUALS[agent.status];
144 const activity = agentActivityText(agent);
145 const modelLabel = formatSubagentModelLabel(agent.model, agent.effort);
146
147 return (
148 <div className="rounded-md px-1.5 py-1">
149 <div className="flex items-start gap-2">
150 <span className="flex h-5 items-center">
151 <StatusDot status={agent.status} />
152 </span>
153 <span className="min-w-0 flex-1">
154 <span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
155 <span className="truncate text-sm font-medium">{agent.title}</span>
156 {agent.role ? (
157 <span className="max-w-28 truncate rounded-sm border border-border/60 px-1 font-mono text-[.65rem] text-muted-foreground">
158 {agent.role}
159 </span>
160 ) : null}
161 <span className="ml-auto flex items-center gap-1 font-mono text-[.7rem] text-muted-foreground/80">
162 <AgentElapsed agent={agent} />
163 {agent.status === "completed" ? (
164 <Check aria-hidden className="size-3 text-success" />
165 ) : null}
166 </span>
167 </span>
168 {activity ? (
169 <span
170 className={cn(
171 "mt-0.5 block truncate text-xs",
172 agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground",
173 )}
174 >
175 {activity}
176 </span>
177 ) : null}
178 <span className="mt-0.5 flex items-center gap-1 font-mono text-[.7rem] text-muted-foreground/70">
179 {modelLabel ? <span className="truncate">{modelLabel}</span> : null}
180 {agent.usage ? (
181 <span className="tabular-nums">
182 {modelLabel ? "· " : ""}
183 {formatSubagentTokenCount(agent.usage.totalTokens)} tok
184 </span>
185 ) : null}
186 {agent.usage?.toolUses !== undefined ? (
187 <span>· {agent.usage.toolUses} tools</span>
188 ) : null}
189 {agent.activationCount > 1 ? <span>· run {agent.activationCount}</span> : null}
190 <span className="sr-only">{visuals.label}</span>
191 </span>
192 </span>
193 </div>
194 </div>
195 );
196}
197
198function workflowIsLive(group: AgentPanelWorkflowGroup): boolean {
199 const status = group.workflow.status;
200 return (
201 status !== "completed" &&
202 status !== "failed" &&
203 status !== "cancelled" &&
204 status !== "interrupted"
205 );
206}
207
208function workflowMembers(group: AgentPanelWorkflowGroup): ReadonlyArray<RuntimeSubagent> {
209 return [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers];
210}
211
212/**
213 * Phase rail: the run's shape at a glance. One segment per phase in order,
214 * separated by chevrons; each segment shows title + one dot per member.
215 * The whole arc (done → live → pending) is visible without scrolling the
216 * member list.
217 */
218function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) {
219 if (group.phases.length === 0) {
220 return null;
221 }
222 return (
223 <div className="flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 pb-1 pt-1.5">
224 {group.phases.map((phase, index) => (
225 <div key={phase.index} className="flex items-center gap-1">
226 {index > 0 ? (
227 <ChevronRight aria-hidden className="size-3 text-muted-foreground/40" />
228 ) : null}
229 <div
230 className={cn(
231 "flex items-center gap-1 rounded-sm border px-1.5 py-0.5",
232 phase.state === "running"
233 ? "border-info/40"
234 : phase.state === "done"
235 ? "border-success/30"
236 : "border-border/50",
237 )}
238 >
239 <span
240 className={cn(
241 "font-mono text-[.65rem]",
242 phase.state === "running"
243 ? "text-info-foreground"
244 : phase.state === "done"
245 ? "text-success-foreground"
246 : "text-muted-foreground/70",
247 )}
248 >
249 {phase.state === "done" ? "✓ " : ""}
250 {phase.title}
251 </span>
252 <span className="flex items-center gap-0.5">
253 {phase.members.length === 0 ? (
254 <span className="font-mono text-[.6rem] text-muted-foreground/50">–</span>
255 ) : (
256 phase.members.map((member) => <StatusDot key={member.id} status={member.status} />)
257 )}
258 </span>
259 </div>
260 </div>
261 ))}
262 </div>
263 );
264}
265
266/**
267 * Read-only workflow script viewer, fetched through the contained
268 * getWorkflowScript RPC (never a raw filesystem read from the client).
269 */
270function WorkflowScriptView({
271 environmentId,
272 threadId,
273 scriptPath,
274 onClose,
275}: {
276 environmentId: EnvironmentId;
277 threadId: ThreadId;
278 scriptPath: string;
279 onClose: () => void;
280}) {
281 const result = useAtomValue(
282 orchestrationEnvironment.workflowScript({ environmentId, input: { threadId, scriptPath } }),
283 );
284 return (
285 <div className="mx-1.5 mb-1 rounded-md border border-border/60 bg-background/60">
286 <div className="flex items-center gap-2 border-b border-border/50 px-2 py-1">
287 <Braces aria-hidden className="size-3 text-muted-foreground" />
288 <span className="truncate font-mono text-[.65rem] text-muted-foreground">
289 {scriptPath.split("/").at(-1)}
290 </span>
291 <button
292 type="button"
293 onClick={onClose}
294 aria-label="Close script"
295 className="ml-auto text-muted-foreground hover:text-foreground"
296 >
297 <X aria-hidden className="size-3" />
298 </button>
299 </div>
300 <div className="max-h-72 overflow-auto p-2">
301 {result._tag === "Success" ? (
302 <pre className="whitespace-pre-wrap break-words font-mono text-[.7rem] leading-relaxed text-foreground/90">
303 {result.value.contents}
304 {result.value.truncated ? "\n… (truncated)" : ""}
305 </pre>
306 ) : result._tag === "Failure" ? (
307 <p className="text-xs text-destructive-foreground">Could not load the script.</p>
308 ) : (
309 <p className="text-xs text-muted-foreground">Loading…</p>
310 )}
311 </div>
312 </div>
313 );
314}
315
316/**
317 * Collapsible phase section (Claude Code Background-tasks pattern): live
318 * phases open by default, done phases collapsed to header + member dot row.
319 * User toggles override the default and stick for the phase's lifetime.
320 */
321function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) {
322 const [userOpen, setUserOpen] = useState<boolean | null>(null);
323 const open = userOpen ?? phase.state === "running";
324 return (
325 <div>
326 <button
327 type="button"
328 onClick={() => setUserOpen(!open)}
329 aria-expanded={open}
330 className={cn(
331 "mt-2 flex w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[.65rem] font-medium uppercase tracking-wider hover:bg-accent/40",
332 phase.state === "done"
333 ? "text-success-foreground"
334 : phase.state === "running"
335 ? "text-info-foreground"
336 : "text-muted-foreground/70",
337 )}
338 >
339 {open ? (
340 <ChevronDown aria-hidden className="size-3 shrink-0" />
341 ) : (
342 <ChevronRight aria-hidden className="size-3 shrink-0" />
343 )}
344 {phase.state === "done" ? <Check aria-hidden className="size-3" /> : null}
345 <span>{phase.title}</span>
346 <span className="font-normal normal-case text-muted-foreground/70">
347 {phase.state === "pending" && phase.members.length === 0
348 ? "pending"
349 : phase.state === "done"
350 ? `${phase.settledCount} done`
351 : `${phase.activeCount} active · ${phase.settledCount} done`}
352 </span>
353 {!open && phase.members.length > 0 ? (
354 <span className="ml-auto flex items-center gap-0.5">
355 {phase.members.map((member) => (
356 <StatusDot key={member.id} status={member.status} />
357 ))}
358 </span>
359 ) : null}
360 </button>
361 {open ? phase.members.map((member) => <AgentRow key={member.id} agent={member} />) : null}
362 </div>
363 );
364}
365
366/** Live workflow: phase rail + full phase tree. */
367function LiveWorkflowSection({
368 group,
369 environmentId,
370 threadId,
371}: {
372 group: AgentPanelWorkflowGroup;
373 environmentId: EnvironmentId | null;
374 threadId: ThreadId | null;
375}) {
376 const [scriptOpen, setScriptOpen] = useState(false);
377 const members = workflowMembers(group);
378 const settled = members.filter(
379 (member) =>
380 member.status === "completed" ||
381 member.status === "failed" ||
382 member.status === "cancelled" ||
383 member.status === "interrupted",
384 ).length;
385 const scriptPath = group.workflow.runHandles?.scriptPath;
386 const canShowScript = scriptPath !== undefined && environmentId !== null && threadId !== null;
387 return (
388 <section className="rounded-lg border border-border/50 bg-card/30 p-1.5">
389 <div className="flex items-center gap-2 px-1.5 pt-0.5 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">
390 <span aria-hidden className="size-1.5 rounded-full bg-info" />
391 <span>{group.workflow.workflowName ?? group.workflow.title}</span>
392 {canShowScript ? (
393 <button
394 type="button"
395 onClick={() => setScriptOpen((value) => !value)}
396 className={cn(
397 "rounded-sm border border-border/60 px-1 font-mono normal-case hover:text-foreground",
398 scriptOpen && "text-foreground",
399 )}
400 aria-expanded={scriptOpen}
401 >
402 {"{}"} script
403 </button>
404 ) : null}
405 <span className="ml-auto font-mono normal-case text-muted-foreground/80">
406 {settled}/{members.length} settled
407 </span>
408 </div>
409 <PhaseRail group={group} />
410 {scriptOpen && canShowScript ? (
411 <WorkflowScriptView
412 environmentId={environmentId}
413 threadId={threadId}
414 scriptPath={scriptPath}
415 onClose={() => setScriptOpen(false)}
416 />
417 ) : null}
418 {group.phases.map((phase) => (
419 <PhaseSection key={phase.index} phase={phase} />
420 ))}
421 {group.unphasedMembers.map((member) => (
422 <AgentRow key={member.id} agent={member} />
423 ))}
424 {group.phases.length === 0 && group.unphasedMembers.length === 0 ? (
425 <AgentRow agent={group.workflow} />
426 ) : null}
427 </section>
428 );
429}
430
431/**
432 * Settled workflow: one summary line. Click toggles the member list — the
433 * only expansion in the panel, at run granularity.
434 */
435function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
436 const [open, setOpen] = useState(false);
437 const members = workflowMembers(group);
438 const failed = members.filter((member) => member.status === "failed").length;
439 // Coordinator usage may already aggregate members (panel-footer rule):
440 // count it only when there are no member rows to sum.
441 const totalTokens = members.reduce(
442 (sum, member) => sum + (member.usage?.totalTokens ?? 0),
443 members.length === 0 ? (group.workflow.usage?.totalTokens ?? 0) : 0,
444 );
445 const elapsed =
446 group.workflow.startedAt && group.workflow.completedAt
447 ? elapsedBetween(group.workflow.startedAt, group.workflow.completedAt)
448 : null;
449 return (
450 <section>
451 <button
452 type="button"
453 onClick={() => setOpen((value) => !value)}
454 className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left hover:bg-accent/40"
455 aria-expanded={open}
456 >
457 <StatusDot status={failed > 0 ? "failed" : group.workflow.status} />
458 <span className="truncate text-sm">
459 {group.workflow.workflowName ?? group.workflow.title}
460 </span>
461 <span className="ml-auto flex items-center gap-1.5 font-mono text-[.7rem] text-muted-foreground/80">
462 {failed > 0 ? <span className="text-destructive-foreground">{failed} failed</span> : null}
463 <span>{members.length} agents</span>
464 <span className="tabular-nums">· {formatSubagentTokenCount(totalTokens)} tok</span>
465 {elapsed ? <span className="tabular-nums">· {elapsed}</span> : null}
466 {open ? (
467 <ChevronDown aria-hidden className="size-3" />
468 ) : (
469 <ChevronRight aria-hidden className="size-3" />
470 )}
471 </span>
472 </button>
473 {open ? (
474 <div className="ms-3 border-s border-border/45 ps-2">
475 {members.map((member) => (
476 <AgentRow key={member.id} agent={member} />
477 ))}
478 </div>
479 ) : null}
480 </section>
481 );
482}
483
484export function AgentsPanel({
485 model,
486 environmentId = null,
487 threadId = null,
488}: {
489 model: AgentPanelModel;
490 environmentId?: EnvironmentId | null;
491 threadId?: ThreadId | null;
492}) {
493 if (!model.hasAgents) {
494 return (
495 <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
496 <Bot aria-hidden className="size-6 text-muted-foreground/60" />
497 <p className="text-sm font-medium">No agents yet</p>
498 <p className="max-w-56 text-xs text-muted-foreground">
499 When this thread spawns subagents or runs a workflow, they show up here with live status,
500 activity, and token usage.
501 </p>
502 </div>
503 );
504 }
505
506 const liveWorkflows = model.workflows.filter(workflowIsLive);
507 const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group));
508 const liveDirect = model.directAgents.filter(
509 (agent) =>
510 agent.status === "running" || agent.status === "pending" || agent.status === "waiting",
511 );
512 const settledDirect = model.directAgents.filter(
513 (agent) =>
514 agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting",
515 );
516
517 return (
518 <div className="flex h-full min-h-0 flex-col">
519 <ScrollArea className="min-h-0 flex-1">
520 <div className="flex flex-col gap-2 p-2">
521 {liveWorkflows.map((group) => (
522 <LiveWorkflowSection
523 key={group.workflow.id}
524 group={group}
525 environmentId={environmentId}
526 threadId={threadId}
527 />
528 ))}
529 {liveDirect.length > 0 ? (
530 <section>
531 <div className="px-1.5 pt-1 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground">
532 Direct spawns
533 </div>
534 {liveDirect.map((agent) => (
535 <AgentRow key={agent.id} agent={agent} />
536 ))}
537 </section>
538 ) : null}
539 {settledWorkflows.length > 0 || settledDirect.length > 0 ? (
540 <section>
541 <div className="px-1.5 pt-2 text-[.65rem] font-medium uppercase tracking-wider text-muted-foreground/70">
542 Earlier
543 </div>
544 {settledWorkflows.map((group) => (
545 <SettledWorkflowSection key={group.workflow.id} group={group} />
546 ))}
547 {settledDirect.map((agent) => (
548 <AgentRow key={agent.id} agent={agent} />
549 ))}
550 </section>
551 ) : null}
552 </div>
553 </ScrollArea>
554 <footer className="flex items-center justify-between border-t border-border/60 px-3 py-1.5 font-mono text-[.7rem] text-muted-foreground">
555 <span className="flex items-center gap-2">
556 {model.runningCount + model.waitingCount > 0 ? (
557 <span className="text-info-foreground">
558 ● {model.runningCount + model.waitingCount} working
559 </span>
560 ) : null}
561 {model.idleCount > 0 ? <span>{model.idleCount} idle</span> : null}
562 {model.settledCount > 0 ? <span>{model.settledCount} settled</span> : null}
563 </span>
564 <span className="tabular-nums">Σ {formatSubagentTokenCount(model.totalTokens)} tok</span>
565 </footer>
566 </div>
567 );
568}
apps/web/src/components/ChatView.tsx+115-1
...@@ -140,6 +140,11 @@ import {...@@ -140,6 +140,11 @@ import {
140 usePreviewMiniPlayerStore,140 usePreviewMiniPlayerStore,
141} from "../previewMiniPlayerStore";141} from "../previewMiniPlayerStore";
142import { RightPanelTabs } from "./RightPanelTabs";142import { RightPanelTabs } from "./RightPanelTabs";
143import { AgentsPanel } from "./AgentsPanel";
144import {
145 deriveAgentPanelModel,
146 foldSubagentActivities,
147} from "@t3tools/client-runtime/state/subagentRuntime";
143import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";148import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
144import { BranchToolbar } from "./BranchToolbar";149import { BranchToolbar } from "./BranchToolbar";
145import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";150import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
...@@ -2052,6 +2057,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2052,6 +2057,18 @@ function ChatViewContent(props: ChatViewProps) {
2052 const phase = derivePhase(activeThread?.session ?? null);2057 const phase = derivePhase(activeThread?.session ?? null);
2053 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;2058 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2054 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);2059 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2060 // Native subagent fold: memoized by activity-list identity, shared by the
2061 // Agents surface, live strip, and workflow cards. v2Projection is null
2062 // until orchestration-v2 lands (source precedence lives in the derive).
2063 // sessionLive derives interruption for agents orphaned by session death.
2064 const agentSessionLive = phase !== "disconnected";
2065 const agentPanelModel = useMemo(
2066 () =>
2067 deriveAgentPanelModel({
2068 agents: foldSubagentActivities(threadActivities, { sessionLive: agentSessionLive }),
2069 }),
2070 [agentSessionLive, threadActivities],
2071 );
2055 const pendingApprovals = useMemo(2072 const pendingApprovals = useMemo(
2056 () => derivePendingApprovals(threadActivities),2073 () => derivePendingApprovals(threadActivities),
2057 [threadActivities],2074 [threadActivities],
...@@ -3134,6 +3151,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3134,6 +3151,10 @@ function ChatViewContent(props: ChatViewProps) {
3134 if (!activeThreadRef || !activeProject) return;3151 if (!activeThreadRef || !activeProject) return;
3135 useRightPanelStore.getState().open(activeThreadRef, "files");3152 useRightPanelStore.getState().open(activeThreadRef, "files");
3136 }, [activeProject, activeThreadRef]);3153 }, [activeProject, activeThreadRef]);
3154 const addAgentsSurface = useCallback(() => {
3155 if (!activeThreadRef) return;
3156 useRightPanelStore.getState().open(activeThreadRef, "agents");
3157 }, [activeThreadRef]);
3137 const openFileSurface = useCallback(3158 const openFileSurface = useCallback(
3138 (relativePath: string) => {3159 (relativePath: string) => {
3139 if (!activeThreadRef || !activeProject) return;3160 if (!activeThreadRef || !activeProject) return;
...@@ -4167,6 +4188,85 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4167,6 +4188,85 @@ function ChatViewContent(props: ChatViewProps) {
4167 switchGitRef,4188 switchGitRef,
4168 updateThreadMetadata,4189 updateThreadMetadata,
4169 ]);4190 ]);
4191 // Background work (subagent fleets, workflow runs, watch loops) can outlive
4192 // the turn; once it settles, the composer stop button is gone, so this
4193 // banner is the only visible stop affordance. Stop routes through the
4194 // stop-everything interrupt: it kills every live background task before
4195 // interrupting, and works by session, so no active turn is needed.
4196 const activeBackgroundLiveness =
4197 !isWorking && activeThread ? (activeThreadShell?.backgroundLiveness ?? null) : null;
4198 const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false);
4199 useEffect(() => {
4200 // "Stopping..." holds until the liveness clears; the interrupt command
4201 // returning only means the request was accepted.
4202 if (activeBackgroundLiveness === null) {
4203 setIsStoppingBackgroundWork(false);
4204 }
4205 }, [activeBackgroundLiveness]);
4206 useEffect(() => {
4207 // Per-thread state: switching threads while A's stop is pending must not
4208 // disable B's Stop button (review finding).
4209 setIsStoppingBackgroundWork(false);
4210 }, [activeThreadId]);
4211 const handleStopBackgroundWork = useCallback(async () => {
4212 if (!activeThread) return;
4213 setIsStoppingBackgroundWork(true);
4214 const result = await interruptThreadTurn({
4215 environmentId,
4216 input: buildThreadTurnInterruptInput(activeThread),
4217 });
4218 if (result._tag === "Failure") {
4219 // Every failure clears the pending state — an interrupted command
4220 // never reached the server, so liveness would hold "Stopping..."
4221 // forever. Only real failures toast.
4222 setIsStoppingBackgroundWork(false);
4223 if (!isAtomCommandInterrupted(result)) {
4224 const error = squashAtomCommandFailure(result);
4225 setThreadError(
4226 activeThread.id,
4227 error instanceof Error ? error.message : "Failed to stop background work.",
4228 );
4229 }
4230 }
4231 }, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
4232 const backgroundLivenessBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4233 if (activeBackgroundLiveness === null || !activeThread) {
4234 return null;
4235 }
4236 const working = activeBackgroundLiveness === "working";
4237 const liveCount = agentPanelModel.liveCount;
4238 return {
4239 id: `background-liveness:${activeThread.id}`,
4240 variant: "default",
4241 icon: (
4242 <span
4243 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}
4244 aria-hidden="true"
4245 />
4246 ),
4247 title: working
4248 ? liveCount > 0
4249 ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
4250 : "Background work running"
4251 : "Monitoring in the background",
4252 actions: (
4253 <Button
4254 size="xs"
4255 variant="outline"
4256 disabled={isStoppingBackgroundWork}
4257 onClick={() => void handleStopBackgroundWork()}
4258 >
4259 {isStoppingBackgroundWork ? "Stopping..." : "Stop"}
4260 </Button>
4261 ),
4262 };
4263 }, [
4264 activeBackgroundLiveness,
4265 activeThread,
4266 agentPanelModel.liveCount,
4267 handleStopBackgroundWork,
4268 isStoppingBackgroundWork,
4269 ]);
4170 // The stack renders items[0] front-most and tucks the rest behind hover, so4270 // The stack renders items[0] front-most and tucks the rest behind hover, so
4171 // ordering is priority: system banners, then the branch-mismatch notice,4271 // ordering is priority: system banners, then the branch-mismatch notice,
4172 // and the informational parked-thread banner last — it must never cover another.4272 // and the informational parked-thread banner last — it must never cover another.
...@@ -4219,12 +4319,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4219,12 +4319,15 @@ function ChatViewContent(props: ChatViewProps) {
4219 void handleSwitchCheckoutToThread();4319 void handleSwitchCheckoutToThread();
4220 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);4320 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
4221 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {4321 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
4322 const backgroundLivenessItems =
4323 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
4222 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];4324 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
4223 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {4325 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
4224 return [...systemComposerBannerItems, ...parkedThreadItems];4326 return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems];
4225 }4327 }
4226 return [4328 return [
4227 ...systemComposerBannerItems,4329 ...systemComposerBannerItems,
4330 ...backgroundLivenessItems,
4228 {4331 {
4229 id: `branch-mismatch:${activeBranchMismatchKey}`,4332 id: `branch-mismatch:${activeBranchMismatchKey}`,
4230 variant: "info",4333 variant: "info",
...@@ -4268,6 +4371,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4268,6 +4371,7 @@ function ChatViewContent(props: ChatViewProps) {
4268 ];4371 ];
4269 }, [4372 }, [
4270 activeBranchMismatchKey,4373 activeBranchMismatchKey,
4374 backgroundLivenessBannerItem,
4271 handleRestoreThreadBranch,4375 handleRestoreThreadBranch,
4272 isRestoringThreadBranch,4376 isRestoringThreadBranch,
4273 localCheckoutBranchMismatch,4377 localCheckoutBranchMismatch,
...@@ -5764,6 +5868,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5764,6 +5868,12 @@ function ChatViewContent(props: ChatViewProps) {
5764 timestampFormat={timestampFormat}5868 timestampFormat={timestampFormat}
5765 mode="embedded"5869 mode="embedded"
5766 />5870 />
5871 ) : activeRightPanelSurface?.kind === "agents" ? (
5872 <AgentsPanel
5873 model={agentPanelModel}
5874 environmentId={activeThreadRef?.environmentId ?? null}
5875 threadId={activeThreadRef?.threadId ?? null}
5876 />
5767 ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&5877 ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&
5768 activeProject &&5878 activeProject &&
5769 activeWorkspaceRoot ? (5879 activeWorkspaceRoot ? (
...@@ -5859,6 +5969,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5859,6 +5969,8 @@ function ChatViewContent(props: ChatViewProps) {
5859 <div className="relative flex min-h-0 flex-1 flex-col">5969 <div className="relative flex min-h-0 flex-1 flex-col">
5860 {/* Messages — LegendList handles virtualization and scrolling internally */}5970 {/* Messages — LegendList handles virtualization and scrolling internally */}
5861 <MessagesTimeline5971 <MessagesTimeline
5972 agentPanelModel={agentPanelModel}
5973 onOpenAgents={addAgentsSurface}
5862 key={activeThread.id}5974 key={activeThread.id}
5863 isWorking={isWorking}5975 isWorking={isWorking}
5864 activeTurnInProgress={isWorking || !latestTurnSettled}5976 activeTurnInProgress={isWorking || !latestTurnSettled}
...@@ -6195,6 +6307,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6195,6 +6307,7 @@ function ChatViewContent(props: ChatViewProps) {
6195 onAddTerminal={addTerminalSurface}6307 onAddTerminal={addTerminalSurface}
6196 onAddDiff={addDiffSurface}6308 onAddDiff={addDiffSurface}
6197 onAddFiles={addFilesSurface}6309 onAddFiles={addFilesSurface}
6310 onAddAgents={addAgentsSurface}
6198 browserAvailable={isPreviewSupportedInRuntime()}6311 browserAvailable={isPreviewSupportedInRuntime()}
6199 diffAvailable={isServerThread && isGitRepo}6312 diffAvailable={isServerThread && isGitRepo}
6200 filesAvailable={activeProject !== null}6313 filesAvailable={activeProject !== null}
...@@ -6222,6 +6335,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6222,6 +6335,7 @@ function ChatViewContent(props: ChatViewProps) {
6222 onAddTerminal={addTerminalSurface}6335 onAddTerminal={addTerminalSurface}
6223 onAddDiff={addDiffSurface}6336 onAddDiff={addDiffSurface}
6224 onAddFiles={addFilesSurface}6337 onAddFiles={addFilesSurface}
6338 onAddAgents={addAgentsSurface}
6225 browserAvailable={isPreviewSupportedInRuntime()}6339 browserAvailable={isPreviewSupportedInRuntime()}
6226 diffAvailable={isServerThread && isGitRepo}6340 diffAvailable={isServerThread && isGitRepo}
6227 filesAvailable={activeProject !== null}6341 filesAvailable={activeProject !== null}
apps/web/src/components/RightPanelTabs.tsx+20-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";1import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";
2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
3import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";3import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
4import {4import {
5 type MouseEvent as ReactMouseEvent,5 type MouseEvent as ReactMouseEvent,
6 type ReactElement,6 type ReactElement,
...@@ -44,6 +44,7 @@ interface RightPanelTabsProps {...@@ -44,6 +44,7 @@ interface RightPanelTabsProps {
44 onAddTerminal: () => void;44 onAddTerminal: () => void;
45 onAddDiff: () => void;45 onAddDiff: () => void;
46 onAddFiles: () => void;46 onAddFiles: () => void;
47 onAddAgents: () => void;
47 browserAvailable: boolean;48 browserAvailable: boolean;
48 diffAvailable: boolean;49 diffAvailable: boolean;
49 filesAvailable: boolean;50 filesAvailable: boolean;
...@@ -91,6 +92,7 @@ function RightPanelEmptyState(props: {...@@ -91,6 +92,7 @@ function RightPanelEmptyState(props: {
91 onAddTerminal: () => void;92 onAddTerminal: () => void;
92 onAddDiff: () => void;93 onAddDiff: () => void;
93 onAddFiles: () => void;94 onAddFiles: () => void;
95 onAddAgents: () => void;
94 browserAvailable: boolean;96 browserAvailable: boolean;
95 diffAvailable: boolean;97 diffAvailable: boolean;
96 filesAvailable: boolean;98 filesAvailable: boolean;
...@@ -128,6 +130,14 @@ function RightPanelEmptyState(props: {...@@ -128,6 +130,14 @@ function RightPanelEmptyState(props: {
128 disabledReason: SURFACE_DISABLED_REASONS.diff,130 disabledReason: SURFACE_DISABLED_REASONS.diff,
129 onClick: props.onAddDiff,131 onClick: props.onAddDiff,
130 },132 },
133 {
134 label: "Agents",
135 description: "Watch subagents and workflows run.",
136 icon: Bot,
137 available: true,
138 disabledReason: null,
139 onClick: props.onAddAgents,
140 },
131 ] as const;141 ] as const;
132142
133 return (143 return (
...@@ -205,6 +215,8 @@ function surfaceTitle(...@@ -205,6 +215,8 @@ function surfaceTitle(
205 );215 );
206 case "plan":216 case "plan":
207 return "Plan";217 return "Plan";
218 case "agents":
219 return "Agents";
208 case "preview": {220 case "preview": {
209 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;221 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
210 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";222 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
...@@ -266,6 +278,8 @@ function SurfaceIcon({...@@ -266,6 +278,8 @@ function SurfaceIcon({
266 return <TerminalSquare className="size-3 shrink-0" />;278 return <TerminalSquare className="size-3 shrink-0" />;
267 case "plan":279 case "plan":
268 return <ClipboardList className="size-3 shrink-0" />;280 return <ClipboardList className="size-3 shrink-0" />;
281 case "agents":
282 return <Bot className="size-3 shrink-0" />;
269 }283 }
270}284}
271285
...@@ -466,6 +480,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -466,6 +480,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
466 <FileDiff />480 <FileDiff />
467 Diff481 Diff
468 </SurfaceMenuItem>482 </SurfaceMenuItem>
483 <SurfaceMenuItem available onClick={props.onAddAgents}>
484 <Bot />
485 Agents
486 </SurfaceMenuItem>
469 </MenuPopup>487 </MenuPopup>
470 </Menu>488 </Menu>
471 ) : null}489 ) : null}
...@@ -480,6 +498,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -480,6 +498,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
480 onAddTerminal={props.onAddTerminal}498 onAddTerminal={props.onAddTerminal}
481 onAddDiff={props.onAddDiff}499 onAddDiff={props.onAddDiff}
482 onAddFiles={props.onAddFiles}500 onAddFiles={props.onAddFiles}
501 onAddAgents={props.onAddAgents}
483 browserAvailable={props.browserAvailable}502 browserAvailable={props.browserAvailable}
484 diffAvailable={props.diffAvailable}503 diffAvailable={props.diffAvailable}
485 filesAvailable={props.filesAvailable}504 filesAvailable={props.filesAvailable}
apps/web/src/components/Sidebar.logic.ts+47-7
...@@ -120,6 +120,7 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {...@@ -120,6 +120,7 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {
120export interface ThreadStatusPill {120export interface ThreadStatusPill {
121 label:121 label:
122 | "Working"122 | "Working"
123 | "Monitoring"
123 | "Connecting"124 | "Connecting"
124 | "Completed"125 | "Completed"
125 | "Pending Approval"126 | "Pending Approval"
...@@ -130,12 +131,16 @@ export interface ThreadStatusPill {...@@ -130,12 +131,16 @@ export interface ThreadStatusPill {
130 pulse: boolean;131 pulse: boolean;
131}132}
132133
134// Rollup order mirrors the per-thread resolver exactly: attention states,
135// then active work, then the actionable plan prompt, then passive
136// monitoring. A Monitoring sibling must never hide a Plan Ready thread.
133const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {137const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
134 "Pending Approval": 5,138 "Pending Approval": 6,
135 "Awaiting Input": 4,139 "Awaiting Input": 5,
136 Working: 3,140 Working: 4,
137 Connecting: 3,141 Connecting: 4,
138 "Plan Ready": 2,142 "Plan Ready": 3,
143 Monitoring: 2,
139 Completed: 1,144 Completed: 1,
140};145};
141146
...@@ -147,6 +152,7 @@ type ThreadStatusInput = Pick<...@@ -147,6 +152,7 @@ type ThreadStatusInput = Pick<
147 | "interactionMode"152 | "interactionMode"
148 | "latestTurn"153 | "latestTurn"
149 | "session"154 | "session"
155 | "backgroundLiveness"
150> & {156> & {
151 lastVisitedAt?: string | undefined;157 lastVisitedAt?: string | undefined;
152};158};
...@@ -424,11 +430,11 @@ export function resolveThreadRowClassName(input: {...@@ -424,11 +430,11 @@ export function resolveThreadRowClassName(input: {
424// whether it finished, asked a question, or proposed a plan.430// whether it finished, asked a question, or proposed a plan.
425// Unread completion is tracked separately: it describes whether a ready431// Unread completion is tracked separately: it describes whether a ready
426// thread needs attention, not what the thread is currently doing.432// thread needs attention, not what the thread is currently doing.
427export type SidebarV2Status = "approval" | "input" | "working" | "failed" | "ready";433export type SidebarV2Status = "approval" | "input" | "working" | "monitoring" | "failed" | "ready";
428434
429type SidebarV2StatusInput = Pick<435type SidebarV2StatusInput = Pick<
430 SidebarThreadSummary,436 SidebarThreadSummary,
431 "hasPendingApprovals" | "hasPendingUserInput" | "session"437 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness"
432>;438>;
433439
434export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status {440export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status {
...@@ -441,9 +447,19 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S...@@ -441,9 +447,19 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S
441 if (thread.session?.status === "running" || thread.session?.status === "starting") {447 if (thread.session?.status === "running" || thread.session?.status === "starting") {
442 return "working";448 return "working";
443 }449 }
450 // A failed session outranks lingering background liveness: the user must
451 // see the failure, not a stale Working (review finding).
444 if (thread.session?.status === "error") {452 if (thread.session?.status === "error") {
445 return "failed";453 return "failed";
446 }454 }
455 // Background work outlives the turn: fleets read as working; monitoring
456 // only when watch loops are the sole live work.
457 if (thread.backgroundLiveness === "working") {
458 return "working";
459 }
460 if (thread.backgroundLiveness === "monitoring") {
461 return "monitoring";
462 }
447 return "ready";463 return "ready";
448}464}
449465
...@@ -616,6 +632,8 @@ export function resolveThreadStatusPill(input: {...@@ -616,6 +632,8 @@ export function resolveThreadStatusPill(input: {
616 };632 };
617 }633 }
618634
635 // An actionable plan prompt outranks lingering background work: it needs
636 // the user's decision, while liveness merely reports (review finding).
619 const hasPlanReadyPrompt =637 const hasPlanReadyPrompt =
620 !thread.hasPendingUserInput &&638 !thread.hasPendingUserInput &&
621 thread.interactionMode === "plan" &&639 thread.interactionMode === "plan" &&
...@@ -630,6 +648,28 @@ export function resolveThreadStatusPill(input: {...@@ -630,6 +648,28 @@ export function resolveThreadStatusPill(input: {
630 };648 };
631 }649 }
632650
651 // The turn can settle while native background work runs on. Subagent and
652 // workflow fleets read as plain Working; Monitoring is reserved for watch
653 // loops (a parent agent babysitting a PR, tailing checks) with no other
654 // live work. Same recede treatment as Working per inbox-zero.
655 if (thread.backgroundLiveness === "working") {
656 return {
657 label: "Working",
658 colorClass: "text-sky-600 dark:text-sky-300/80",
659 dotClass: "bg-sky-500 dark:bg-sky-300/80",
660 pulse: true,
661 };
662 }
663
664 if (thread.backgroundLiveness === "monitoring") {
665 return {
666 label: "Monitoring",
667 colorClass: "text-sky-600 dark:text-sky-300/80",
668 dotClass: "bg-sky-500 dark:bg-sky-300/80",
669 pulse: false,
670 };
671 }
672
633 if (hasUnseenCompletion(thread)) {673 if (hasUnseenCompletion(thread)) {
634 return {674 return {
635 label: "Completed",675 label: "Completed",
apps/web/src/components/SidebarV2.tsx+28-19
...@@ -482,7 +482,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -482,7 +482,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
482 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:482 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:
483 // working threads aren't your problem yet) — only the colored status label483 // working threads aren't your problem yet) — only the colored status label
484 // stands out.484 // stands out.
485 const isInFlight = status === "working" || status === "approval" || status === "input";485 const isInFlight =
486 status === "working" || status === "monitoring" || status === "approval" || status === "input";
486 const shouldRecede =487 const shouldRecede =
487 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;488 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
488 // Status hues follow the system-wide convention set by sidebar v1 and the489 // Status hues follow the system-wide convention set by sidebar v1 and the
...@@ -496,37 +497,45 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {...@@ -496,37 +497,45 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
496 className:497 className:
497 "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400",498 "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400",
498 }499 }
499 : status === "approval"500 : status === "monitoring"
500 ? {501 ? {
501 label: "Approval",502 // Steady label, no duty-cycled shimmer: monitoring is calm
503 // background presence, not active progress (monitoring-pill D6).
504 label: "Monitoring",
502 icon: null,505 icon: null,
503 className: "text-amber-700 dark:text-amber-300",506 className: "text-sky-600 dark:text-sky-400",
504 }507 }
505 : status === "input"508 : status === "approval"
506 ? {509 ? {
507 label: "Input",510 label: "Approval",
508 icon: null,511 icon: null,
509 className: "text-indigo-600 dark:text-indigo-300",512 className: "text-amber-700 dark:text-amber-300",
510 }513 }
511 : status === "failed"514 : status === "input"
512 ? {515 ? {
513 label: "Failed",516 label: "Input",
514 icon: null,517 icon: null,
515 className: "text-red-700 dark:text-red-300",518 className: "text-indigo-600 dark:text-indigo-300",
516 }519 }
517 : isWoke520 : status === "failed"
518 ? {521 ? {
519 label: "Woke",522 label: "Failed",
520 icon: "woke" as const,523 icon: null,
521 className: "text-amber-700 dark:text-amber-300",524 className: "text-red-700 dark:text-red-300",
522 }525 }
523 : isUnread526 : isWoke
524 ? {527 ? {
525 label: "Done",528 label: "Woke",
526 icon: "done" as const,529 icon: "woke" as const,
527 className: "text-emerald-700 dark:text-emerald-300",530 className: "text-amber-700 dark:text-amber-300",
528 }531 }
529 : null;532 : isUnread
533 ? {
534 label: "Done",
535 icon: "done" as const,
536 className: "text-emerald-700 dark:text-emerald-300",
537 }
538 : null;
530539
531 const gitCwd = thread.worktreePath ?? props.projectCwd;540 const gitCwd = thread.worktreePath ?? props.projectCwd;
532 const gitStatus = useEnvironmentQuery(541 const gitStatus = useEnvironmentQuery(
apps/web/src/components/chat/MessagesTimeline.logic.ts+37-14
...@@ -352,9 +352,16 @@ function deriveTurnFolds(input: {...@@ -352,9 +352,16 @@ function deriveTurnFolds(input: {
352 }352 }
353 const hiddenEntryIds = new Set<string>();353 const hiddenEntryIds = new Set<string>();
354 for (const entry of group.entries) {354 for (const entry of group.entries) {
355 if (entry.id !== group.terminalEntry?.id) {355 if (entry.id === group.terminalEntry?.id) {
356 hiddenEntryIds.add(entry.id);356 continue;
357 }357 }
358 // Agent-spawn CTA rows never fold: workflows outlive their launching
359 // turn (dynamic spawns, background execution), and folding the CTA
360 // when the turn settles makes a still-running fleet invisible.
361 if (entry.kind === "work" && entry.entry.agentSpawn !== undefined) {
362 continue;
363 }
364 hiddenEntryIds.add(entry.id);
358 }365 }
359 if (hiddenEntryIds.size === 0) {366 if (hiddenEntryIds.size === 0) {
360 continue;367 continue;
...@@ -489,9 +496,21 @@ export function deriveMessagesTimelineRows(input: {...@@ -489,9 +496,21 @@ export function deriveMessagesTimelineRows(input: {
489 } else {496 } else {
490 const groupId = `work-group:${timelineEntry.id}`;497 const groupId = `work-group:${timelineEntry.id}`;
491 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;498 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
492 const hiddenEntries = visibleGroupedEntries.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);499 // Agent-spawn CTA rows are always visible: a running fleet must
493 const visibleEntries = visibleGroupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);500 // never hide behind a "+N tool calls" toggle. Selection is by
494 const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries;501 // membership (spawn OR recent-tail), preserving the group's
502 // chronological order in both collapsed and expanded states
503 // (review finding: concatenating two filtered lists moved a
504 // mid-group spawn row above earlier tool rows).
505 const overflowCandidates = visibleGroupedEntries.filter(
506 (entry) => entry.agentSpawn === undefined,
507 );
508 const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
509 const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
510 const visibleEntries = visibleGroupedEntries.filter(
511 (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
512 );
513 const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;
495514
496 for (const workEntry of renderedEntries) {515 for (const workEntry of renderedEntries) {
497 nextRows.push({516 nextRows.push({
...@@ -502,15 +521,19 @@ export function deriveMessagesTimelineRows(input: {...@@ -502,15 +521,19 @@ export function deriveMessagesTimelineRows(input: {
502 });521 });
503 }522 }
504523
505 nextRows.push({524 if (hiddenEntries.length > 0) {
506 kind: "work-toggle",525 nextRows.push({
507 id: `work-toggle:${timelineEntry.id}`,526 kind: "work-toggle",
508 createdAt: timelineEntry.createdAt,527 id: `work-toggle:${timelineEntry.id}`,
509 groupId,528 createdAt: timelineEntry.createdAt,
510 hiddenCount: hiddenEntries.length,529 groupId,
511 expanded,530 hiddenCount: hiddenEntries.length,
512 onlyToolEntries: visibleGroupedEntries.every((entry) => workLogEntryIsToolLike(entry)),531 expanded,
513 });532 onlyToolEntries: visibleGroupedEntries.every((entry) =>
533 workLogEntryIsToolLike(entry),
534 ),
535 });
536 }
514 }537 }
515 }538 }
516 index = cursor - 1;539 index = cursor - 1;
apps/web/src/components/chat/MessagesTimeline.tsx+131-1
...@@ -6,6 +6,14 @@ import {...@@ -6,6 +6,14 @@ import {
6 type TurnId,6 type TurnId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";8import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
9import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime";
10import {
11 emptyAgentPanelModel,
12 formatSubagentTokenCount,
13} from "@t3tools/client-runtime/state/subagentRuntime";
14
15const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel();
16const NOOP_OPEN_AGENTS = () => {};
9import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";17import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
10import {18import {
11 createContext,19 createContext,
...@@ -135,6 +143,8 @@ interface TimelineRowSharedState {...@@ -135,6 +143,8 @@ interface TimelineRowSharedState {
135 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;143 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
136 onToggleTurnFold: (turnId: TurnId) => void;144 onToggleTurnFold: (turnId: TurnId) => void;
137 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;145 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
146 agentPanelModel: AgentPanelModel;
147 onOpenAgents: () => void;
138}148}
139149
140interface TimelineRowActivityState {150interface TimelineRowActivityState {
...@@ -156,6 +166,8 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d...@@ -156,6 +166,8 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d
156// ---------------------------------------------------------------------------166// ---------------------------------------------------------------------------
157167
158interface MessagesTimelineProps {168interface MessagesTimelineProps {
169 agentPanelModel?: AgentPanelModel;
170 onOpenAgents?: () => void;
159 isWorking: boolean;171 isWorking: boolean;
160 activeTurnInProgress: boolean;172 activeTurnInProgress: boolean;
161 activeTurnStartedAt: string | null;173 activeTurnStartedAt: string | null;
...@@ -194,6 +206,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -194,6 +206,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
194 isWorking,206 isWorking,
195 activeTurnInProgress,207 activeTurnInProgress,
196 activeTurnStartedAt,208 activeTurnStartedAt,
209 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
210 onOpenAgents = NOOP_OPEN_AGENTS,
197 listRef,211 listRef,
198 timelineEntries,212 timelineEntries,
199 latestTurn,213 latestTurn,
...@@ -430,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -430,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
430 onOpenTurnDiff,444 onOpenTurnDiff,
431 onToggleTurnFold,445 onToggleTurnFold,
432 onToggleWorkGroup,446 onToggleWorkGroup,
447 agentPanelModel,
448 onOpenAgents,
433 }),449 }),
434 [450 [
435 timestampFormat,451 timestampFormat,
...@@ -444,6 +460,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -444,6 +460,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
444 onOpenTurnDiff,460 onOpenTurnDiff,
445 onToggleTurnFold,461 onToggleTurnFold,
446 onToggleWorkGroup,462 onToggleWorkGroup,
463 agentPanelModel,
464 onOpenAgents,
447 ],465 ],
448 );466 );
449 const activityState = useMemo<TimelineRowActivityState>(467 const activityState = useMemo<TimelineRowActivityState>(
...@@ -1897,8 +1915,14 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {...@@ -1897,8 +1915,14 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
1897 case "mcp_tool_call":1915 case "mcp_tool_call":
1898 return "wrench";1916 return "wrench";
1899 case "dynamic_tool_call":1917 case "dynamic_tool_call":
1900 case "collab_agent_tool_call":
1901 return "hammer";1918 return "hammer";
1919 case "collab_agent_tool_call":
1920 return "bot";
1921 }
1922
1923 // Subagent lifecycle rows (grouped by taskId) get agent identity chrome.
1924 if (workEntry.taskId) {
1925 return "bot";
1902 }1926 }
19031927
1904 return workToneIcon(workEntry.tone).iconName;1928 return workToneIcon(workEntry.tone).iconName;
...@@ -1921,9 +1945,115 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {...@@ -1921,9 +1945,115 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
19211945
1922const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();1946const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
19231947
1948/**
1949 * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn
1950 * batch). Live status is derived from the shared agent panel model at render
1951 * time — the row itself never re-renders a roster; the Agents panel is the
1952 * only roster. Freezes to past tense when every member settles. Static dot,
1953 * no animation.
1954 */
1955const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: TimelineWorkEntry }) {
1956 const { workEntry } = props;
1957 const { agentPanelModel, onOpenAgents } = use(TimelineRowCtx);
1958 const spawn = workEntry.agentSpawn;
1959 if (!spawn) {
1960 return null;
1961 }
1962
1963 const memberIds = new Set(spawn.agentTaskIds);
1964 const workflowGroup = spawn.workflowId
1965 ? agentPanelModel.workflows.find((group) => group.workflow.id === spawn.workflowId)
1966 : undefined;
1967 const agents = workflowGroup
1968 ? [...workflowGroup.phases.flatMap((phase) => phase.members), ...workflowGroup.unphasedMembers]
1969 : agentPanelModel.directAgents.filter((agent) => memberIds.has(agent.id));
1970 const agentCount = Math.max(
1971 agents.length,
1972 Math.max(memberIds.size - (spawn.workflowId ? 1 : 0), 0),
1973 );
1974
1975 const running = agents.filter(
1976 (agent) => agent.status === "running" || agent.status === "pending",
1977 ).length;
1978 const waiting = agents.filter((agent) => agent.status === "waiting").length;
1979 const failed = agents.filter((agent) => agent.status === "failed").length;
1980 // The coordinator's own status is authoritative for workflows: dynamic
1981 // spawns mean the member list can be momentarily all-settled while the
1982 // run is still mid-flight (the "completed" lie from live testing). A
1983 // workflow is live until the coordinator itself reaches a terminal state.
1984 const coordinatorStatus = workflowGroup?.workflow.status;
1985 const coordinatorSettled =
1986 coordinatorStatus === "completed" ||
1987 coordinatorStatus === "failed" ||
1988 coordinatorStatus === "cancelled" ||
1989 coordinatorStatus === "interrupted";
1990 const live = workflowGroup !== undefined ? !coordinatorSettled : running + waiting > 0;
1991 // Same rule as the panel footer: providers may aggregate member usage into
1992 // the coordinator, so count the coordinator only when no members exist.
1993 const totalTokens = agents.reduce(
1994 (sum, agent) => sum + (agent.usage?.totalTokens ?? 0),
1995 spawn.workflowId && agents.length === 0 ? (workflowGroup?.workflow.usage?.totalTokens ?? 0) : 0,
1996 );
1997
1998 const livePhase = workflowGroup?.phases.find((phase) => phase.state === "running");
1999 const workflowName =
2000 workflowGroup?.workflow.workflowName ?? workflowGroup?.workflow.title ?? null;
2001
2002 // One steady in-flight presentation (monitoring-pill rule): waiting and
2003 // stalled agents read as working; only settled states differentiate.
2004 const working = running + waiting;
2005 const dotClass = live ? "bg-info" : failed > 0 ? "bg-destructive" : "bg-success";
2006 const lead = live
2007 ? `Kicked off ${agentCount} subagent${agentCount === 1 ? "" : "s"}`
2008 : `Ran ${agentCount} subagent${agentCount === 1 ? "" : "s"}`;
2009 const status = live
2010 ? livePhase
2011 ? `${livePhase.title} · ${livePhase.activeCount} working`
2012 : working > 0
2013 ? `${working} working`
2014 : "working"
2015 : failed > 0
2016 ? `${failed} failed`
2017 : "✓ completed";
2018
2019 return (
2020 <button
2021 type="button"
2022 onClick={onOpenAgents}
2023 className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
2024 >
2025 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2026 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
2027 <span className="min-w-0 truncate">
2028 <span className="font-medium">{lead}</span>
2029 {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
2030 </span>
2031 <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
2032 <span>{status}</span>
2033 {totalTokens > 0 ? (
2034 <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
2035 ) : null}
2036 <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
2037 </span>
2038 </button>
2039 );
2040});
2041
1924const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {2042const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
1925 workEntry: TimelineWorkEntry;2043 workEntry: TimelineWorkEntry;
1926 workspaceRoot: string | undefined;2044 workspaceRoot: string | undefined;
2045}) {
2046 const { workEntry, workspaceRoot } = props;
2047 // Before any hooks: spawn CTA rows render their own component.
2048 if (workEntry.agentSpawn) {
2049 return <AgentSpawnCtaRow workEntry={workEntry} />;
2050 }
2051 return <PlainWorkEntryRow workEntry={workEntry} workspaceRoot={workspaceRoot} />;
2052});
2053
2054const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
2055 workEntry: TimelineWorkEntry;
2056 workspaceRoot: string | undefined;
1927}) {2057}) {
1928 const { workEntry, workspaceRoot } = props;2058 const { workEntry, workspaceRoot } = props;
1929 const activity = use(TimelineRowActivityCtx);2059 const activity = use(TimelineRowActivityCtx);
apps/web/src/rightPanelStore.ts+14-3
...@@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware";...@@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware";
1414
15import { resolveStorage } from "./lib/storage";15import { resolveStorage } from "./lib/storage";
1616
17export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const;17export const RIGHT_PANEL_KINDS = [
18 "plan",
19 "diff",
20 "files",
21 "file",
22 "preview",
23 "terminal",
24 "agents",
25] as const;
18export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];26export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
1927
20export type RightPanelSurface =28export type RightPanelSurface =
...@@ -37,10 +45,11 @@ export type RightPanelSurface =...@@ -37,10 +45,11 @@ export type RightPanelSurface =
37 revealLine: number | null;45 revealLine: number | null;
38 revealRequestId: number;46 revealRequestId: number;
39 }47 }
40 | { id: "plan"; kind: "plan" };48 | { id: "plan"; kind: "plan" }
49 | { id: "agents"; kind: "agents" };
4150
42const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";51const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
43const RIGHT_PANEL_STORAGE_VERSION = 7;52const RIGHT_PANEL_STORAGE_VERSION = 8;
4453
45export interface ThreadRightPanelState {54export interface ThreadRightPanelState {
46 isOpen: boolean;55 isOpen: boolean;
...@@ -92,6 +101,8 @@ const singletonSurface = (...@@ -92,6 +101,8 @@ const singletonSurface = (
92 return { id: "files", kind };101 return { id: "files", kind };
93 case "plan":102 case "plan":
94 return { id: "plan", kind };103 return { id: "plan", kind };
104 case "agents":
105 return { id: "agents", kind };
95 }106 }
96};107};
97108
apps/web/src/session-logic.test.ts+226-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 classifyTaskAgentKind,
2 EventId,3 EventId,
3 MessageId,4 MessageId,
4 ThreadId,5 ThreadId,
...@@ -35,7 +36,19 @@ function makeActivity(overrides: {...@@ -35,7 +36,19 @@ function makeActivity(overrides: {
35 turnId?: string;36 turnId?: string;
36 sequence?: number;37 sequence?: number;
37}): OrchestrationThreadActivity {38}): OrchestrationThreadActivity {
38 const payload = overrides.payload ?? {};39 // Fixtures model post-ingestion rows: ingestion stamps agentKind on every
40 // task.* payload. Pass an explicit agentKind to model legacy rows.
41 const rawPayload = overrides.payload ?? {};
42 const payload =
43 overrides.kind?.startsWith("task.") && !("agentKind" in rawPayload)
44 ? {
45 ...rawPayload,
46 agentKind: classifyTaskAgentKind({
47 taskType: typeof rawPayload.taskType === "string" ? rawPayload.taskType : undefined,
48 agentId: typeof rawPayload.agentId === "string" ? rawPayload.agentId : undefined,
49 }),
50 }
51 : rawPayload;
39 return {52 return {
40 id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`),53 id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`),
41 createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z",54 createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z",
...@@ -1686,3 +1699,215 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -1686,3 +1699,215 @@ describe("deriveActiveWorkStartedAt", () => {
1686 ).toBe("2026-02-27T21:11:00.000Z");1699 ).toBe("2026-02-27T21:11:00.000Z");
1687 });1700 });
1688});1701});
1702
1703describe("deriveWorkLogEntries quiet-timeline guarantee", () => {
1704 it("N concurrent subagents produce exactly N lifecycle rows, zero attributed tool rows", () => {
1705 const activities: OrchestrationThreadActivity[] = [];
1706 for (let agent = 0; agent < 5; agent += 1) {
1707 const taskId = `task-${agent}`;
1708 // Progress ticks (several per agent) + attributed tool rows.
1709 for (let tick = 0; tick < 4; tick += 1) {
1710 activities.push(
1711 makeActivity({
1712 kind: "task.progress",
1713 summary: `agent ${agent} tick ${tick}`,
1714 tone: "info",
1715 payload: { taskId, summary: `working ${tick}`, role: "explorer" },
1716 turnId: "turn-batch",
1717 sequence: agent * 20 + tick,
1718 }),
1719 );
1720 activities.push(
1721 makeActivity({
1722 kind: "tool.completed",
1723 summary: "Read",
1724 payload: { itemType: "dynamic_tool_call", agentId: taskId },
1725 sequence: agent * 20 + 10 + tick,
1726 }),
1727 );
1728 }
1729 activities.push(
1730 makeActivity({
1731 kind: "task.completed",
1732 summary: "Task completed",
1733 tone: "info",
1734 payload: {
1735 taskId,
1736 status: "completed",
1737 summary: `agent ${agent} done`,
1738 role: "explorer",
1739 },
1740 turnId: "turn-batch",
1741 sequence: agent * 20 + 19,
1742 }),
1743 );
1744 }
1745
1746 const entries = deriveWorkLogEntries(activities);
1747 // A1 CTA design: all direct spawns in one turn collapse into ONE
1748 // call-to-action row carrying the batch's agent ids.
1749 const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined);
1750 expect(spawnRows).toHaveLength(1);
1751 expect(spawnRows[0]!.agentSpawn!.agentTaskIds).toHaveLength(5);
1752 expect(spawnRows[0]!.agentSpawn!.workflowId).toBeNull();
1753 // No agent-attributed tool rows leak into the main log.
1754 expect(entries.some((entry) => entry.sourceActivityKind?.startsWith("tool."))).toBe(false);
1755 });
1756
1757 it("a workflow run and its members collapse into one CTA row keyed to the coordinator", () => {
1758 const entries = deriveWorkLogEntries([
1759 makeActivity({
1760 kind: "task.progress",
1761 summary: "coordinator",
1762 tone: "info",
1763 payload: { taskId: "wf-1", taskType: "local_workflow", workflowName: "math-check" },
1764 sequence: 1,
1765 }),
1766 makeActivity({
1767 kind: "task.progress",
1768 summary: "member",
1769 tone: "info",
1770 payload: { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" },
1771 sequence: 2,
1772 }),
1773 makeActivity({
1774 kind: "task.completed",
1775 summary: "member done",
1776 tone: "info",
1777 payload: { taskId: "wf-1:wf:1", status: "completed", parentAgentId: "wf-1" },
1778 sequence: 3,
1779 }),
1780 ]);
1781 const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined);
1782 expect(spawnRows).toHaveLength(1);
1783 expect(spawnRows[0]!.agentSpawn!.workflowId).toBe("wf-1");
1784 expect(spawnRows[0]!.agentSpawn!.agentTaskIds).toEqual(
1785 expect.arrayContaining(["wf-1", "wf-1:wf:0", "wf-1:wf:1"]),
1786 );
1787 });
1788
1789 it("keeps unattributed tool rows (over-hiding loses the only signal)", () => {
1790 const entries = deriveWorkLogEntries([
1791 makeActivity({
1792 kind: "tool.completed",
1793 summary: "Bash",
1794 payload: { itemType: "command_execution", command: "ls" },
1795 }),
1796 ]);
1797 expect(entries).toHaveLength(1);
1798 });
1799
1800 it("folds timelineBypass agent rows into one CTA (Codex children, workflow members)", () => {
1801 // Codex children carry their parent's spawn turn (spawnTurnId stamping),
1802 // which is what batches a fleet into one CTA.
1803 const entries = deriveWorkLogEntries([
1804 makeActivity({
1805 kind: "task.progress",
1806 summary: "child work",
1807 tone: "info",
1808 payload: { taskId: "child-1", timelineBypass: true },
1809 turnId: "turn-spawn",
1810 }),
1811 makeActivity({
1812 kind: "task.progress",
1813 summary: "child work again",
1814 tone: "info",
1815 payload: { taskId: "child-2", timelineBypass: true },
1816 turnId: "turn-spawn",
1817 }),
1818 ]);
1819 // Not suppressed outright (a Codex fleet's rows are ALL bypassed and
1820 // still need a CTA anchor) — but never more than the batch's single row.
1821 expect(entries).toHaveLength(1);
1822 expect(entries[0]!.agentSpawn?.agentTaskIds).toEqual(["child-1", "child-2"]);
1823 });
1824
1825 it("timelineBypass non-agent rows (background shells) stay suppressed", () => {
1826 const entries = deriveWorkLogEntries([
1827 makeActivity({
1828 kind: "task.progress",
1829 summary: "stall",
1830 tone: "info",
1831 payload: { taskId: "sh-1", taskType: "local_bash", timelineBypass: true },
1832 }),
1833 ]);
1834 expect(entries).toHaveLength(0);
1835 });
1836
1837 it("drops task.updated and tool.progress from the work log (fold input only)", () => {
1838 const entries = deriveWorkLogEntries([
1839 makeActivity({
1840 kind: "task.updated",
1841 summary: "Task running",
1842 tone: "info",
1843 payload: { taskId: "task-1", status: "running" },
1844 }),
1845 makeActivity({
1846 kind: "tool.progress",
1847 summary: "Read",
1848 tone: "info",
1849 payload: { taskId: "task-1", toolName: "Read" },
1850 }),
1851 ]);
1852 expect(entries).toHaveLength(0);
1853 });
1854});
1855
1856describe("rerun workflows", () => {
1857 it("turn-less direct spawns do not collapse into one global batch", () => {
1858 // Rows that lost their turn id (defensive path) group per task, so two
1859 // unrelated turn-less spawns never merge into one immortal CTA.
1860 const entries = deriveWorkLogEntries([
1861 makeActivity({
1862 kind: "task.started",
1863 summary: "Task started",
1864 payload: { taskId: "loose-1", taskType: "local_agent", role: "a" },
1865 sequence: 1,
1866 }),
1867 makeActivity({
1868 kind: "task.started",
1869 summary: "Task started",
1870 payload: { taskId: "loose-2", taskType: "local_agent", role: "b" },
1871 sequence: 2,
1872 }),
1873 ]);
1874 const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined);
1875 expect(spawnRows).toHaveLength(2);
1876 expect(spawnRows.map((row) => row.agentSpawn!.agentTaskIds)).toEqual([
1877 ["loose-1"],
1878 ["loose-2"],
1879 ]);
1880 });
1881
1882 it("each workflow run gets its own CTA row (distinct coordinator ids)", () => {
1883 const entries = deriveWorkLogEntries([
1884 makeActivity({
1885 kind: "task.progress",
1886 summary: "run 1",
1887 tone: "info",
1888 payload: { taskId: "wf-run1", taskType: "local_workflow", workflowName: "math-check" },
1889 turnId: "turn-1",
1890 sequence: 1,
1891 }),
1892 makeActivity({
1893 kind: "task.completed",
1894 summary: "run 1 done",
1895 tone: "info",
1896 payload: { taskId: "wf-run1", status: "completed", taskType: "local_workflow" },
1897 turnId: "turn-1",
1898 sequence: 2,
1899 }),
1900 makeActivity({
1901 kind: "task.progress",
1902 summary: "run 2",
1903 tone: "info",
1904 payload: { taskId: "wf-run2", taskType: "local_workflow", workflowName: "math-check" },
1905 turnId: "turn-2",
1906 sequence: 3,
1907 }),
1908 ]);
1909 const spawnRows = entries.filter((entry) => entry.agentSpawn !== undefined);
1910 expect(spawnRows.map((row) => row.agentSpawn!.workflowId)).toEqual(["wf-run1", "wf-run2"]);
1911 expect(spawnRows.map((row) => row.turnId)).toEqual(["turn-1", "turn-2"]);
1912 });
1913});
apps/web/src/session-logic.ts+203-2
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Option from "effect/Option";1import * as Option from "effect/Option";
2import * as Arr from "effect/Array";2import * as Arr from "effect/Array";
3import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
3import {4import {
4 ApprovalRequestId,5 ApprovalRequestId,
5 isToolLifecycleItemType,6 isToolLifecycleItemType,
...@@ -78,12 +79,30 @@ export interface WorkLogEntry {...@@ -78,12 +79,30 @@ export interface WorkLogEntry {
78 toolLifecycleStatus?: WorkLogToolLifecycleStatus;79 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
79 /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */80 /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */
80 sourceActivityKind?: OrchestrationThreadActivity["kind"];81 sourceActivityKind?: OrchestrationThreadActivity["kind"];
82 /** Grouping key for subagent lifecycle rows (one row per agent). */
83 taskId?: string;
84 /** Agent role (subagent_type) for labeled timeline rows. */
85 agentRole?: string;
86 /**
87 * Present on agent-spawn CTA rows: one per workflow run or per-turn batch
88 * of direct spawns. The row renders as a call-to-action ("Kicked off N
89 * subagents") whose live status is derived from the agent panel model at
90 * render time; clicking opens the Agents panel.
91 */
92 agentSpawn?: {
93 /** Workflow coordinator taskId, or null for a direct-spawn batch. */
94 workflowId: string | null;
95 agentTaskIds: ReadonlyArray<string>;
96 };
81}97}
8298
83interface DerivedWorkLogEntry extends WorkLogEntry {99interface DerivedWorkLogEntry extends WorkLogEntry {
84 activityKind: OrchestrationThreadActivity["kind"];100 activityKind: OrchestrationThreadActivity["kind"];
85 collapseKey?: string;101 collapseKey?: string;
86 toolCallId?: string;102 toolCallId?: string;
103 isWorkflowCoordinator?: boolean;
104 /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn CTAs. */
105 isBackgroundTask?: boolean;
87}106}
88107
89export interface PendingApproval {108export interface PendingApproval {
...@@ -250,6 +269,12 @@ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {...@@ -250,6 +269,12 @@ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
250269
251/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */270/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
252export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {271export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
272 // Spawn CTA rows are never neutral-hidden: mid-run they derive from
273 // task.progress (tone "thinking") and the neutral filter was swallowing
274 // them exactly while the fleet ran — the one moment they matter most.
275 if (entry.agentSpawn !== undefined) {
276 return false;
277 }
253 if (!workLogEntryIsToolLike(entry)) {278 if (!workLogEntryIsToolLike(entry)) {
254 return false;279 return false;
255 }280 }
...@@ -624,6 +649,67 @@ export function hasActionableProposedPlan(...@@ -624,6 +649,67 @@ export function hasActionableProposedPlan(
624 return proposedPlan !== null && proposedPlan.implementedAt === null;649 return proposedPlan !== null && proposedPlan.implementedAt === null;
625}650}
626651
652/**
653 * Quiet-timeline guarantee: the work log carries the parent's narrative plus
654 * at most one row per agent. Everything an agent does internally lives in the
655 * Agents surface:
656 * - timelineBypass rows (Codex children, workflow members) never render here;
657 * - tool rows attributed to an owning agent (payload.agentId) are re-homed;
658 * - task.progress ticks collapse into one row per taskId;
659 * - task.updated is fold input only (status patches are not narrative).
660 * Unattributed rows always stay: over-hiding loses the only terminal signal.
661 */
662/** Agent (non-background) task.started rows seed spawn CTA batches. */
663function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean {
664 const payload =
665 activity.payload && typeof activity.payload === "object"
666 ? (activity.payload as Record<string, unknown>)
667 : null;
668 if (!payload || typeof payload.taskId !== "string") {
669 return false;
670 }
671 return !isBackgroundTaskActivity(payload);
672}
673
674function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean {
675 const payload =
676 activity.payload && typeof activity.payload === "object"
677 ? (activity.payload as Record<string, unknown>)
678 : null;
679 if (!payload) {
680 return false;
681 }
682 const isTaskRow =
683 activity.kind === "task.started" ||
684 activity.kind === "task.progress" ||
685 activity.kind === "task.updated" ||
686 activity.kind === "task.completed";
687 // Task rows classify by the server stamp: a subagent's own background
688 // shell (agentId + "background") is agent-internal, but a nested AGENT
689 // (agentId + "agent") stays visible so its rows can anchor a spawn CTA
690 // (review finding: hiding on agentId alone removed nested agents and
691 // their anchors). Bypassed agent lifecycle rows also pass — collapse
692 // folds every such row into its batch's single CTA row, which is how
693 // Codex children (whose rows are ALL bypassed) get an anchor at the
694 // spawn point.
695 if (isTaskRow) {
696 const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
697 if (ownedByAgent || payload.timelineBypass === true) {
698 const isAgentTaskRow =
699 activity.kind !== "task.updated" &&
700 typeof payload.taskId === "string" &&
701 !isBackgroundTaskActivity(payload);
702 return !isAgentTaskRow;
703 }
704 return false;
705 }
706 if (payload.timelineBypass === true) {
707 return true;
708 }
709 // Non-task rows (attributed tool activity) owned by an agent are internal.
710 return typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
711}
712
627export function deriveWorkLogEntries(713export function deriveWorkLogEntries(
628 activities: ReadonlyArray<OrchestrationThreadActivity>,714 activities: ReadonlyArray<OrchestrationThreadActivity>,
629): WorkLogEntry[] {715): WorkLogEntry[] {
...@@ -631,10 +717,17 @@ export function deriveWorkLogEntries(...@@ -631,10 +717,17 @@ export function deriveWorkLogEntries(
631 const entries: DerivedWorkLogEntry[] = [];717 const entries: DerivedWorkLogEntry[] = [];
632 for (const activity of ordered) {718 for (const activity of ordered) {
633 if (activity.kind === "tool.started") continue;719 if (activity.kind === "tool.started") continue;
634 if (activity.kind === "task.started") continue;720 // Agent task.started rows are CTA seeds: they carry the true spawn turn,
721 // which is the batch key (completions of background subagents arrive
722 // under later synthetic turns and must not start new batches). They
723 // collapse into the batch's single CTA row, never render standalone.
724 if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue;
725 if (activity.kind === "task.updated") continue;
726 if (activity.kind === "tool.progress") continue;
635 if (activity.kind === "context-window.updated") continue;727 if (activity.kind === "context-window.updated") continue;
636 if (activity.summary === "Checkpoint captured") continue;728 if (activity.summary === "Checkpoint captured") continue;
637 if (isPlanBoundaryToolActivity(activity)) continue;729 if (isPlanBoundaryToolActivity(activity)) continue;
730 if (isAgentInternalActivity(activity)) continue;
638 entries.push(toDerivedWorkLogEntry(activity));731 entries.push(toDerivedWorkLogEntry(activity));
639 }732 }
640 return collapseDerivedWorkLogEntries(entries).map((entry) => {733 return collapseDerivedWorkLogEntries(entries).map((entry) => {
...@@ -682,7 +775,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -682,7 +775,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
682 const commandPreview = extractToolCommand(payload);775 const commandPreview = extractToolCommand(payload);
683 const changedFiles = extractChangedFiles(payload);776 const changedFiles = extractChangedFiles(payload);
684 const title = extractToolTitle(payload);777 const title = extractToolTitle(payload);
685 const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";778 const isTaskActivity =
779 activity.kind === "task.started" ||
780 activity.kind === "task.progress" ||
781 activity.kind === "task.completed";
686 const taskSummary =782 const taskSummary =
687 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0783 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
688 ? payload.summary784 ? payload.summary
...@@ -756,6 +852,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -756,6 +852,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
756 if (toolLifecycleStatus) {852 if (toolLifecycleStatus) {
757 entry.toolLifecycleStatus = toolLifecycleStatus;853 entry.toolLifecycleStatus = toolLifecycleStatus;
758 }854 }
855 if (isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0) {
856 entry.taskId = payload.taskId;
857 }
858 if (isTaskActivity && typeof payload?.role === "string" && payload.role.length > 0) {
859 entry.agentRole = payload.role;
860 }
861 if (
862 isTaskActivity &&
863 (payload?.taskType === "local_workflow" ||
864 (typeof payload?.workflowName === "string" && payload.workflowName.length > 0))
865 ) {
866 entry.isWorkflowCoordinator = true;
867 }
868 if (isTaskActivity && payload && isBackgroundTaskActivity(payload)) {
869 entry.isBackgroundTask = true;
870 }
759 const collapseKey = deriveToolLifecycleCollapseKey(entry);871 const collapseKey = deriveToolLifecycleCollapseKey(entry);
760 if (collapseKey) {872 if (collapseKey) {
761 entry.collapseKey = collapseKey;873 entry.collapseKey = collapseKey;
...@@ -763,11 +875,92 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo...@@ -763,11 +875,92 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
763 return entry;875 return entry;
764}876}
765877
878/**
879 * Spawn-group key for a subagent lifecycle row. Workflow members and their
880 * coordinator share the coordinator's group; direct spawns batch per turn.
881 * One CTA row per group (A1 design): "Kicked off N subagents".
882 */
883function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string {
884 const taskId = entry.taskId ?? "";
885 const workflowSlot = taskId.indexOf(":wf:");
886 if (workflowSlot !== -1) {
887 return `wf:${taskId.slice(0, workflowSlot)}`;
888 }
889 if (entry.agentSpawn?.workflowId) {
890 return `wf:${entry.agentSpawn.workflowId}`;
891 }
892 if (entry.isWorkflowCoordinator) {
893 return `wf:${taskId}`;
894 }
895 // No turn id means no batch signal at all: fall back to one group per
896 // task. Unrelated turn-less spawns (separate fleets whose rows lost their
897 // turn) must not collapse into one immortal "direct:no-turn" CTA
898 // accumulating every agent the thread ever ran (review finding). Adapters
899 // stamp spawn turns (Codex spawnTurnId; Claude rows ride real turns), so
900 // this path is defensive.
901 return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`;
902}
903
766function collapseDerivedWorkLogEntries(904function collapseDerivedWorkLogEntries(
767 entries: ReadonlyArray<DerivedWorkLogEntry>,905 entries: ReadonlyArray<DerivedWorkLogEntry>,
768): DerivedWorkLogEntry[] {906): DerivedWorkLogEntry[] {
769 const collapsed: DerivedWorkLogEntry[] = [];907 const collapsed: DerivedWorkLogEntry[] = [];
908 // Subagent rows collapse by spawn group, not adjacency: a workflow run (or
909 // a turn's batch of direct spawns) is ONE narrative event in the chat — a
910 // CTA row that opens the Agents panel — no matter how many agents it
911 // contains or how their progress rows interleave (quiet-timeline
912 // guarantee).
913 const spawnRowIndex = new Map<string, number>();
914 // Batch membership is decided once, at the FIRST row seen for a taskId.
915 // Claude background subagents settle between turns, so their completion
916 // rows carry fresh synthetic turn ids (or none) — keying each row by its
917 // own turn splintered one batch into a stream of "Kicked off N subagents"
918 // rows (live-test finding, thread 7ac7ef05).
919 const groupKeyByTaskId = new Map<string, string>();
770 for (const entry of entries) {920 for (const entry of entries) {
921 const isTaskRow =
922 entry.taskId !== undefined &&
923 !entry.isBackgroundTask &&
924 (entry.activityKind === "task.started" ||
925 entry.activityKind === "task.progress" ||
926 entry.activityKind === "task.completed");
927 if (isTaskRow && entry.taskId !== undefined) {
928 const rememberedKey = groupKeyByTaskId.get(entry.taskId);
929 const groupKey = rememberedKey ?? agentSpawnGroupKey(entry);
930 if (rememberedKey === undefined) {
931 groupKeyByTaskId.set(entry.taskId, groupKey);
932 }
933 const workflowId = groupKey.startsWith("wf:") ? groupKey.slice(3) : null;
934 const existingIndex = spawnRowIndex.get(groupKey);
935 if (existingIndex !== undefined) {
936 const existing = collapsed[existingIndex]!;
937 const agentTaskIds = existing.agentSpawn?.agentTaskIds.includes(entry.taskId)
938 ? existing.agentSpawn.agentTaskIds
939 : [...(existing.agentSpawn?.agentTaskIds ?? []), entry.taskId];
940 collapsed[existingIndex] = {
941 ...mergeDerivedWorkLogEntries(existing, entry),
942 // The CTA row keeps the group's ANCHOR identity, not the last
943 // agent's: id/createdAt/turnId stay pinned to the spawn point so
944 // the row renders where the run launched instead of drifting to
945 // the newest progress tick (mid-run it drifted below the whole
946 // conversation, reading as "no visualization"), and the stable id
947 // keeps React state/virtualization sane.
948 id: existing.id,
949 createdAt: existing.createdAt,
950 turnId: existing.turnId ?? null,
951 ...(existing.taskId !== undefined ? { taskId: existing.taskId } : {}),
952 label: existing.label,
953 agentSpawn: { workflowId, agentTaskIds },
954 };
955 continue;
956 }
957 spawnRowIndex.set(groupKey, collapsed.length);
958 collapsed.push({
959 ...entry,
960 agentSpawn: { workflowId, agentTaskIds: [entry.taskId] },
961 });
962 continue;
963 }
771 const previous = collapsed.at(-1);964 const previous = collapsed.at(-1);
772 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {965 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
773 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);966 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
...@@ -847,6 +1040,14 @@ function mergeChangedFiles(...@@ -847,6 +1040,14 @@ function mergeChangedFiles(
847}1040}
8481041
849function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {1042function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | undefined {
1043 // Subagent lifecycle rows collapse by agent identity: one row per agent,
1044 // progress ticks fold into it, the terminal row wins the label.
1045 if (
1046 entry.taskId &&
1047 (entry.activityKind === "task.progress" || entry.activityKind === "task.completed")
1048 ) {
1049 return `task${entry.taskId}`;
1050 }
850 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {1051 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
851 return undefined;1052 return undefined;
852 }1053 }
packages/client-runtime/package.json+4
...@@ -123,6 +123,10 @@...@@ -123,6 +123,10 @@
123 "types": "./src/state/threads.ts",123 "types": "./src/state/threads.ts",
124 "default": "./src/state/threads.ts"124 "default": "./src/state/threads.ts"
125 },125 },
126 "./state/subagentRuntime": {
127 "types": "./src/state/subagentRuntime.ts",
128 "default": "./src/state/subagentRuntime.ts"
129 },
126 "./state/thread-sort": {130 "./state/thread-sort": {
127 "types": "./src/state/threadSort.ts",131 "types": "./src/state/threadSort.ts",
128 "default": "./src/state/threadSort.ts"132 "default": "./src/state/threadSort.ts"
packages/client-runtime/src/state/orchestration.ts+7
...@@ -12,6 +12,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>(...@@ -12,6 +12,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
12 label: "environment-data:orchestration:turn-diff",12 label: "environment-data:orchestration:turn-diff",
13 tag: ORCHESTRATION_WS_METHODS.getTurnDiff,13 tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
14 }),14 }),
15 workflowScript: createEnvironmentRpcQueryAtomFamily(runtime, {
16 label: "environment-data:orchestration:workflow-script",
17 tag: ORCHESTRATION_WS_METHODS.getWorkflowScript,
18 // Scripts are immutable per run: cache generously.
19 staleTimeMs: 300_000,
20 idleTtlMs: 300_000,
21 }),
15 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {22 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
16 label: "environment-data:orchestration:full-thread-diff",23 label: "environment-data:orchestration:full-thread-diff",
17 tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,24 tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff,
packages/client-runtime/src/state/subagentRuntime.test.ts created+772
...@@ -0,0 +1,772 @@
1import { describe, expect, it } from "vite-plus/test";
2import { classifyTaskAgentKind, type OrchestrationThreadActivity } from "@t3tools/contracts";
3import {
4 deriveAgentPanelModel,
5 foldSubagentActivities,
6 formatSubagentModelLabel,
7 formatSubagentTokenCount,
8 isAgentAttributedToolActivity,
9 isSubagentActivityKind,
10 isTimelineBypassActivity,
11 workflowCardMembers,
12} from "./subagentRuntime.ts";
13
14let sequence = 0;
15/**
16 * Fixtures model POST-INGESTION rows: ingestion stamps agentKind on every
17 * task.* payload, so the helper stamps too (same classifier). Pass an
18 * explicit agentKind (or agentKind: undefined via legacy()) to override.
19 */
20function activity(
21 kind: string,
22 payload: Record<string, unknown>,
23 at = `2026-08-01T10:00:${String(sequence).padStart(2, "0")}.000Z`,
24): OrchestrationThreadActivity {
25 sequence += 1;
26 const stamped =
27 kind.startsWith("task.") && !("agentKind" in payload)
28 ? {
29 ...payload,
30 agentKind: classifyTaskAgentKind({
31 taskType: typeof payload.taskType === "string" ? payload.taskType : undefined,
32 agentId: typeof payload.agentId === "string" ? payload.agentId : undefined,
33 }),
34 }
35 : payload;
36 return {
37 id: `activity-${sequence}`,
38 tone: "info",
39 kind,
40 summary: kind,
41 payload: stamped,
42 turnId: null,
43 createdAt: at,
44 } as unknown as OrchestrationThreadActivity;
45}
46
47/** A pre-stamp row (legacy thread / old server): no agentKind at all. */
48function legacyActivity(
49 kind: string,
50 payload: Record<string, unknown>,
51): OrchestrationThreadActivity {
52 sequence += 1;
53 return {
54 id: `activity-${sequence}`,
55 tone: "info",
56 kind,
57 summary: kind,
58 payload,
59 turnId: null,
60 createdAt: `2026-08-01T10:00:${String(sequence).padStart(2, "0")}.000Z`,
61 } as unknown as OrchestrationThreadActivity;
62}
63
64function fold(rows: ReadonlyArray<OrchestrationThreadActivity>) {
65 return foldSubagentActivities(rows);
66}
67
68describe("foldSubagentActivities", () => {
69 it("builds an agent from start → progress → completion", () => {
70 const agents = fold([
71 activity("task.started", {
72 taskId: "task-1",
73 title: "Audit auth flow",
74 role: "explorer",
75 }),
76 activity("task.progress", {
77 taskId: "task-1",
78 lastToolName: "Read",
79 typedUsage: { totalTokens: 1200, toolUses: 3 },
80 }),
81 activity("task.completed", {
82 taskId: "task-1",
83 status: "completed",
84 summary: "Found 2 issues",
85 typedUsage: { totalTokens: 5000, toolUses: 9 },
86 }),
87 ]);
88 expect(agents).toHaveLength(1);
89 const agent = agents[0]!;
90 expect(agent.title).toBe("Audit auth flow");
91 expect(agent.role).toBe("explorer");
92 expect(agent.status).toBe("completed");
93 expect(agent.result).toBe("Found 2 issues");
94 expect(agent.usage?.totalTokens).toBe(5000);
95 expect(agent.activationCount).toBe(1);
96 expect(agent.completedAt).not.toBeNull();
97 });
98
99 it("progress can create an agent when its start row aged out of retention", () => {
100 const agents = fold([
101 activity("task.progress", {
102 taskId: "task-orphan",
103 title: "Recovered agent",
104 role: "verifier",
105 typedUsage: { totalTokens: 100 },
106 }),
107 ]);
108 expect(agents).toHaveLength(1);
109 expect(agents[0]!.title).toBe("Recovered agent");
110 expect(agents[0]!.status).toBe("running");
111 });
112
113 it("completion before start stays terminal; a late start only fills metadata", () => {
114 const agents = fold([
115 activity("task.completed", {
116 taskId: "task-2",
117 status: "failed",
118 summary: "boom",
119 role: "fixer",
120 }),
121 activity("task.started", { taskId: "task-2", title: "Late metadata", role: "fixer" }),
122 ]);
123 expect(agents).toHaveLength(1);
124 const agent = agents[0]!;
125 expect(agent.title).toBe("Late metadata");
126 expect(agent.role).toBe("fixer");
127 // The late start must NOT reopen the terminal activation as a new run.
128 expect(agent.status).toBe("failed");
129 expect(agent.error).toBe("boom");
130 });
131
132 it("duplicate terminal events are idempotent (timestamps do not slide)", () => {
133 const agents = fold([
134 activity("task.started", { taskId: "task-3", taskType: "local_agent" }),
135 activity(
136 "task.completed",
137 { taskId: "task-3", status: "completed" },
138 "2026-08-01T11:00:00.000Z",
139 ),
140 activity(
141 "task.completed",
142 { taskId: "task-3", status: "completed" },
143 "2026-08-01T12:00:00.000Z",
144 ),
145 ]);
146 expect(agents[0]!.completedAt).toBe("2026-08-01T11:00:00.000Z");
147 });
148
149 it("reactivation increments the run count and clears result/error", () => {
150 const agents = fold([
151 activity("task.started", { taskId: "task-4", taskType: "local_agent" }),
152 activity("task.completed", { taskId: "task-4", status: "completed", summary: "run 1 done" }),
153 activity("task.updated", { taskId: "task-4", status: "running" }),
154 ]);
155 const agent = agents[0]!;
156 expect(agent.activationCount).toBe(2);
157 expect(agent.result).toBeNull();
158 expect(agent.completedAt).toBeNull();
159 expect(agent.status).toBe("running");
160 });
161
162 it("idle is nonterminal: an idle agent resumes without losing identity", () => {
163 const agents = fold([
164 activity("task.started", { taskId: "codex-child-1", title: "Marlow", role: "explorer" }),
165 activity("task.updated", { taskId: "codex-child-1", status: "idle" }),
166 activity("task.updated", { taskId: "codex-child-1", status: "running" }),
167 ]);
168 expect(agents).toHaveLength(1);
169 expect(agents[0]!.activationCount).toBe(2);
170 expect(agents[0]!.status).toBe("running");
171 });
172
173 it("cumulative usage max-merges: duplicate and late frames never shrink or double-count", () => {
174 const agents = fold([
175 activity("task.started", { taskId: "task-5", taskType: "local_agent" }),
176 activity("task.progress", {
177 taskId: "task-5",
178 typedUsage: { totalTokens: 900, inputTokens: 700 },
179 }),
180 activity("task.progress", {
181 taskId: "task-5",
182 typedUsage: { totalTokens: 900, inputTokens: 700 },
183 }),
184 activity("task.progress", { taskId: "task-5", typedUsage: { totalTokens: 500 } }),
185 ]);
186 expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 });
187 });
188
189 it("partial terminal usage preserves known breakdown fields", () => {
190 const agents = fold([
191 activity("task.started", { taskId: "task-6", taskType: "local_agent" }),
192 activity("task.progress", {
193 taskId: "task-6",
194 typedUsage: { totalTokens: 800, inputTokens: 600, outputTokens: 150 },
195 }),
196 activity("task.completed", {
197 taskId: "task-6",
198 status: "completed",
199 typedUsage: { totalTokens: 1000 },
200 }),
201 ]);
202 expect(agents[0]!.usage).toEqual({ totalTokens: 1000, inputTokens: 600, outputTokens: 150 });
203 });
204
205 it("skips malformed rows individually without failing the fold", () => {
206 const agents = fold([
207 activity("task.started", { taskId: "task-7", title: "Good", taskType: "local_agent" }),
208 activity("task.progress", { bogus: true }),
209 activity("task.progress", { taskId: 42 }),
210 ]);
211 expect(agents).toHaveLength(1);
212 expect(agents[0]!.title).toBe("Good");
213 });
214
215 it("bounds repeated strings at 180 chars and the activity ring at 6 deduped entries", () => {
216 const long = "x".repeat(500);
217 const rows = [activity("task.started", { taskId: "task-8", taskType: "local_agent" })];
218 for (let i = 0; i < 10; i += 1) {
219 rows.push(activity("task.progress", { taskId: "task-8", summary: `${long}-${i}` }));
220 }
221 rows.push(activity("task.progress", { taskId: "task-8", summary: `${long}-9` }));
222 const agents = fold(rows);
223 const agent = agents[0]!;
224 expect(agent.recentActivity.length).toBeLessThanOrEqual(6);
225 for (const entry of agent.recentActivity) {
226 expect(entry.summary.length).toBeLessThanOrEqual(180);
227 }
228 // Consecutive identical summaries dedupe (truncation makes them equal).
229 const summaries = agent.recentActivity.map((entry) => entry.summary);
230 expect(new Set(summaries).size).toBe(summaries.length);
231 });
232
233 it("plan tasks are not agents", () => {
234 const agents = fold([activity("task.started", { taskId: "plan-1", taskType: "plan" })]);
235 expect(agents).toHaveLength(0);
236 });
237
238 it("workflow members key by stable slot and attach to their coordinator", () => {
239 const agents = fold([
240 activity("task.started", {
241 taskId: "wf-1",
242 taskType: "local_workflow",
243 title: "audit-auth-flow",
244 workflowName: "audit-auth-flow",
245 }),
246 activity("task.progress", {
247 taskId: "wf-1",
248 phases: [
249 { index: 0, title: "Audit" },
250 { index: 1, title: "Verify" },
251 ],
252 }),
253 activity("task.progress", {
254 taskId: "wf-1:wf:0",
255 title: "audit:entrypoints",
256 status: "running",
257 parentAgentId: "wf-1",
258 agentIndex: 0,
259 phaseIndex: 0,
260 phaseTitle: "Audit",
261 timelineBypass: true,
262 }),
263 ]);
264 const workflow = agents.find((agent) => agent.id === "wf-1");
265 const member = agents.find((agent) => agent.id === "wf-1:wf:0");
266 expect(workflow?.kind).toBe("workflow");
267 expect(workflow?.phases).toEqual([
268 { index: 0, title: "Audit" },
269 { index: 1, title: "Verify" },
270 ]);
271 expect(member?.kind).toBe("workflow_agent");
272 expect(member?.parentAgentId).toBe("wf-1");
273 });
274
275 it("a workflow member retry (attempt bump) is a reactivation of the same slot", () => {
276 const agents = fold([
277 activity("task.progress", {
278 taskId: "wf-2:wf:1",
279 title: "verify:refresh",
280 status: "failed",
281 error: "attempt 1 died",
282 parentAgentId: "wf-2",
283 attempt: 1,
284 }),
285 activity("task.progress", {
286 taskId: "wf-2:wf:1",
287 title: "verify:refresh",
288 status: "running",
289 parentAgentId: "wf-2",
290 attempt: 2,
291 }),
292 ]);
293 expect(agents).toHaveLength(1);
294 const member = agents[0]!;
295 expect(member.activationCount).toBeGreaterThanOrEqual(2);
296 expect(member.error).toBeNull();
297 expect(member.status).toBe("running");
298 });
299
300 it("drops non-http(s) session urls at the fold boundary", () => {
301 const agents = fold([
302 activity("task.started", {
303 taskId: "wf-3",
304 taskType: "local_workflow",
305 runHandles: { sessionUrl: "javascript:alert(1)", runId: "run-1" },
306 }),
307 ]);
308 expect(agents[0]!.runHandles?.sessionUrl).toBeUndefined();
309 expect(agents[0]!.runHandles?.runId).toBe("run-1");
310 });
311});
312
313describe("deriveAgentPanelModel", () => {
314 const roster = fold([
315 activity("task.started", { taskId: "wf-1", taskType: "local_workflow", title: "audit" }),
316 activity("task.progress", {
317 taskId: "wf-1",
318 phases: [
319 { index: 0, title: "Audit" },
320 { index: 1, title: "Verify" },
321 ],
322 }),
323 activity("task.progress", {
324 taskId: "wf-1:wf:0",
325 title: "audit:a",
326 status: "completed",
327 parentAgentId: "wf-1",
328 agentIndex: 0,
329 phaseIndex: 0,
330 }),
331 activity("task.completed", { taskId: "wf-1:wf:0", status: "completed", parentAgentId: "wf-1" }),
332 activity("task.progress", {
333 taskId: "wf-1:wf:1",
334 title: "verify:b",
335 status: "running",
336 parentAgentId: "wf-1",
337 agentIndex: 1,
338 phaseIndex: 1,
339 typedUsage: { totalTokens: 4000 },
340 }),
341 activity("task.started", { taskId: "direct-1", title: "Marlow", role: "explorer" }),
342 activity("task.updated", { taskId: "direct-1", status: "idle" }),
343 ]);
344
345 it("groups workflow members by phase and separates direct spawns", () => {
346 const model = deriveAgentPanelModel({ agents: roster });
347 expect(model.workflows).toHaveLength(1);
348 const group = model.workflows[0]!;
349 expect(group.phases).toHaveLength(2);
350 expect(group.phases[0]!.state).toBe("done");
351 expect(group.phases[1]!.state).toBe("running");
352 expect(model.directAgents.map((agent) => agent.id)).toEqual(["direct-1"]);
353 });
354
355 it("counts idle deliberately and waiting as active", () => {
356 const model = deriveAgentPanelModel({ agents: roster });
357 expect(model.idleCount).toBe(1);
358 // wf-1 coordinator + member 1 running.
359 expect(model.runningCount).toBeGreaterThanOrEqual(1);
360 expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe(
361 roster.length,
362 );
363 });
364
365 it("a phase with only pending members never reads as running", () => {
366 const pendingRoster = fold([
367 activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }),
368 activity("task.progress", {
369 taskId: "wf-9",
370 phases: [{ index: 0, title: "Fix" }],
371 }),
372 activity("task.progress", {
373 taskId: "wf-9:wf:0",
374 title: "fixer",
375 status: "pending",
376 parentAgentId: "wf-9",
377 agentIndex: 0,
378 phaseIndex: 0,
379 }),
380 ]);
381 const model = deriveAgentPanelModel({ agents: pendingRoster });
382 // "pending" counts as active liveness (queued work), so the phase reads
383 // running only if a member is genuinely pending/running — this asserts
384 // the settled-count rule: no member settled, phase not done.
385 expect(model.workflows[0]!.phases[0]!.state).not.toBe("done");
386 });
387
388 it("v2 projection wins outright and sources are never merged", () => {
389 const v2Agent = { ...roster[0]!, id: "v2-only", title: "From v2" };
390 const model = deriveAgentPanelModel({ agents: roster, v2Projection: [v2Agent] });
391 const allIds = [
392 ...model.workflows.map((group) => group.workflow.id),
393 ...model.directAgents.map((agent) => agent.id),
394 ];
395 expect(allIds).toContain("v2-only");
396 expect(allIds).not.toContain("direct-1");
397 });
398
399 it("orphaned members fall back to the direct list", () => {
400 const orphans = fold([
401 activity("task.progress", {
402 taskId: "gone:wf:0",
403 title: "orphan",
404 status: "running",
405 parentAgentId: "gone",
406 }),
407 ]);
408 const model = deriveAgentPanelModel({ agents: orphans });
409 expect(model.workflows).toHaveLength(0);
410 expect(model.directAgents.map((agent) => agent.id)).toEqual(["gone:wf:0"]);
411 });
412});
413
414describe("workflowCardMembers", () => {
415 it("orders by urgency (failed, running, waiting) and reports overflow", () => {
416 const roster = fold([
417 activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }),
418 ...[..."abcdefghij"].map((letter, index) =>
419 activity("task.progress", {
420 taskId: `wf-1:wf:${index}`,
421 title: `agent-${letter}`,
422 status: index === 3 ? "failed" : index < 3 ? "completed" : "running",
423 ...(index === 3 ? { error: "died" } : {}),
424 parentAgentId: "wf-1",
425 agentIndex: index,
426 phaseIndex: 0,
427 phaseTitle: "Work",
428 }),
429 ),
430 ]);
431 const model = deriveAgentPanelModel({ agents: roster });
432 const { visible, overflow } = workflowCardMembers(model.workflows[0]!, 8);
433 expect(visible).toHaveLength(8);
434 expect(overflow).toBe(2);
435 expect(visible[0]!.status).toBe("failed");
436 expect(visible.filter((agent) => agent.status === "completed").length).toBeLessThanOrEqual(2);
437 });
438});
439
440describe("timeline predicates", () => {
441 it("recognizes subagent activity kinds as fold input", () => {
442 for (const kind of [
443 "task.started",
444 "task.progress",
445 "task.updated",
446 "task.completed",
447 "tool.progress",
448 ]) {
449 expect(isSubagentActivityKind(kind)).toBe(true);
450 }
451 expect(isSubagentActivityKind("tool.completed")).toBe(false);
452 });
453
454 it("attributed tool rows are re-homed; unattributed rows stay in the timeline", () => {
455 expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: "task-1" }))).toBe(
456 true,
457 );
458 expect(isAgentAttributedToolActivity(activity("tool.completed", {}))).toBe(false);
459 expect(isAgentAttributedToolActivity(activity("tool.completed", { agentId: " " }))).toBe(
460 false,
461 );
462 });
463
464 it("timelineBypass rows never render in the parent chat", () => {
465 expect(isTimelineBypassActivity(activity("task.progress", { timelineBypass: true }))).toBe(
466 true,
467 );
468 expect(isTimelineBypassActivity(activity("task.progress", {}))).toBe(false);
469 });
470});
471
472describe("formatSubagentTokenCount", () => {
473 it("formats plain counters", () => {
474 expect(formatSubagentTokenCount(950)).toBe("950");
475 expect(formatSubagentTokenCount(41200)).toBe("41.2k");
476 expect(formatSubagentTokenCount(247000)).toBe("247k");
477 expect(formatSubagentTokenCount(1_400_000)).toBe("1.4M");
478 });
479});
480
481describe("model and effort attribution", () => {
482 it("carries model/effort from start rows and refines model from later rows", () => {
483 const agents = fold([
484 activity("task.started", {
485 taskId: "task-m",
486 title: "Verify math",
487 model: "sonnet",
488 effort: "high",
489 }),
490 // Later row refines with the authoritative API model id; effort absent
491 // must not clear the known value.
492 activity("task.progress", { taskId: "task-m", model: "claude-sonnet-5[1m]" }),
493 ]);
494 expect(agents).toHaveLength(1);
495 expect(agents[0]!.model).toBe("claude-sonnet-5[1m]");
496 expect(agents[0]!.effort).toBe("high");
497 });
498
499 it("formatSubagentModelLabel compacts ids and appends effort", () => {
500 expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high");
501 expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4");
502 expect(formatSubagentModelLabel("gpt-5.6-sol", "low")).toBe("gpt-5.6-sol · low");
503 expect(formatSubagentModelLabel(null, "high")).toBeNull();
504 });
505});
506
507describe("background task exclusion", () => {
508 it("shells and monitors never join the roster (from any lifecycle row)", () => {
509 const agents = fold([
510 activity("task.started", { taskId: "shell-1", taskType: "shell", title: "Run 12s stall" }),
511 activity("task.progress", { taskId: "shell-2", taskType: "shell", title: "Run stall" }),
512 activity("task.completed", { taskId: "mon-1", taskType: "monitor", status: "completed" }),
513 activity("task.started", { taskId: "agent-1", taskType: "subagent", title: "Real agent" }),
514 ]);
515 expect(agents.map((agent) => agent.id)).toEqual(["agent-1"]);
516 });
517
518 it("rows without a taskType stay in the roster (workflow members, Codex children)", () => {
519 const agents = fold([
520 activity("task.progress", { taskId: "wf-1:wf:0", status: "running", parentAgentId: "wf-1" }),
521 ]);
522 expect(agents).toHaveLength(1);
523 });
524
525 it("the server stamp is the only classifier: no stamp means no roster row", () => {
526 const agents = fold([
527 // Stamped background: agent-looking fields don't matter.
528 activity("task.started", {
529 taskId: "bg-1",
530 agentKind: "background",
531 role: "watcher",
532 model: "sonnet",
533 }),
534 // Stamped agent: plain row still joins the roster.
535 activity("task.started", { taskId: "ag-1", agentKind: "agent", detail: "plain row" }),
536 // Legacy pre-stamp rows (old threads/servers) stay in the work log —
537 // exactly their pre-upgrade behavior.
538 legacyActivity("task.started", { taskId: "old-task", detail: "tailing logs" }),
539 legacyActivity("task.progress", { taskId: "old-task", summary: "still tailing" }),
540 ]);
541 expect(agents.map((agent) => agent.id)).toEqual(["ag-1"]);
542 });
543
544 it("membership is sticky: a stampless later row still reaches a known agent", () => {
545 const agents = fold([
546 activity("task.started", { taskId: "a1", taskType: "local_agent", title: "Agent" }),
547 // Terminal row missing the stamp (defensive: adapters synthesize some
548 // rows) — sticky membership still routes it to the agent.
549 legacyActivity("task.completed", { taskId: "a1", status: "completed", summary: "done" }),
550 ]);
551 expect(agents).toHaveLength(1);
552 expect(agents[0]!.status).toBe("completed");
553 expect(agents[0]!.result).toBe("done");
554 });
555});
556
557describe("session-derived interruption", () => {
558 it("dead session interrupts live agents but preserves idle and settled", () => {
559 const rows = [
560 activity("task.started", { taskId: "live-1", taskType: "local_agent" }),
561 activity("task.started", { taskId: "idle-1", taskType: "local_agent" }),
562 activity("task.updated", { taskId: "idle-1", status: "idle" }),
563 activity("task.started", { taskId: "done-1", taskType: "local_agent" }),
564 activity("task.completed", { taskId: "done-1", status: "completed" }),
565 ];
566 const dead = foldSubagentActivities(rows, { sessionLive: false });
567 expect(dead.find((agent) => agent.id === "live-1")?.status).toBe("interrupted");
568 expect(dead.find((agent) => agent.id === "idle-1")?.status).toBe("idle");
569 expect(dead.find((agent) => agent.id === "done-1")?.status).toBe("completed");
570 const alive = foldSubagentActivities(rows, { sessionLive: true });
571 expect(alive.find((agent) => agent.id === "live-1")?.status).toBe("running");
572 });
573});
574
575describe("terminal robustness", () => {
576 it("task.updated creating an agent (start row aged out) counts one activation", () => {
577 const agents = fold([
578 activity("task.updated", { taskId: "orphan-u", status: "running", role: "worker" }),
579 ]);
580 expect(agents).toHaveLength(1);
581 expect(agents[0]!.activationCount).toBe(1);
582 expect(agents[0]!.status).toBe("running");
583 });
584
585 it("a late start after a terminal task.updated does not reopen the run", () => {
586 const agents = fold([
587 activity("task.updated", { taskId: "t1", status: "failed", role: "worker" }),
588 activity("task.started", { taskId: "t1", taskType: "local_agent", title: "Late" }),
589 ]);
590 expect(agents).toHaveLength(1);
591 expect(agents[0]!.status).toBe("failed");
592 expect(agents[0]!.title).toBe("Late");
593 });
594
595 it("a completion after a terminal task.updated still enriches result and usage", () => {
596 // Claude commonly emits terminal task.updated before task.completed;
597 // the completion carries the summary and final usage the update lacked.
598 const agents = fold([
599 activity("task.started", { taskId: "te-1", taskType: "local_agent" }),
600 activity(
601 "task.updated",
602 { taskId: "te-1", status: "completed", endedAt: "2026-08-01T10:59:00.000Z" },
603 "2026-08-01T11:00:00.000Z",
604 ),
605 activity(
606 "task.completed",
607 {
608 taskId: "te-1",
609 status: "completed",
610 summary: "final answer",
611 typedUsage: { totalTokens: 4200, toolUses: 7 },
612 },
613 "2026-08-01T11:00:01.000Z",
614 ),
615 ]);
616 const agent = agents[0]!;
617 expect(agent.status).toBe("completed");
618 expect(agent.result).toBe("final answer");
619 expect(agent.usage?.totalTokens).toBe(4200);
620 // Timestamps stay pinned to the transition that settled the run.
621 expect(agent.completedAt).toBe("2026-08-01T10:59:00.000Z");
622 });
623
624 it("duplicate completions keep the FIRST result, not the last", () => {
625 const agents = fold([
626 activity("task.started", { taskId: "t2", taskType: "local_agent" }),
627 activity("task.completed", { taskId: "t2", status: "completed", summary: "first result" }),
628 activity("task.completed", { taskId: "t2", status: "completed", summary: "second result" }),
629 ]);
630 expect(agents[0]!.result).toBe("first result");
631 });
632
633 it("provider endedAt wins over ingestion time on the settling transition", () => {
634 const agents = fold([
635 activity("task.started", { taskId: "t3", taskType: "local_agent" }),
636 activity(
637 "task.updated",
638 { taskId: "t3", status: "failed", endedAt: "2026-08-01T09:59:59.000Z" },
639 "2026-08-01T10:00:30.000Z",
640 ),
641 ]);
642 expect(agents[0]!.completedAt).toBe("2026-08-01T09:59:59.000Z");
643 });
644
645 it("workflow retries count each attempt once", () => {
646 const agents = fold([
647 activity("task.progress", {
648 taskId: "wf-r:wf:0",
649 parentAgentId: "wf-r",
650 status: "running",
651 attempt: 1,
652 }),
653 activity("task.progress", {
654 taskId: "wf-r:wf:0",
655 parentAgentId: "wf-r",
656 status: "failed",
657 attempt: 1,
658 }),
659 activity("task.progress", {
660 taskId: "wf-r:wf:0",
661 parentAgentId: "wf-r",
662 status: "running",
663 attempt: 2,
664 }),
665 ]);
666 expect(agents[0]!.activationCount).toBe(2);
667 });
668});
669
670describe("phase membership", () => {
671 it("members with unknown phase indices land in unphasedMembers, never vanish", () => {
672 const model = deriveAgentPanelModel({
673 agents: fold([
674 activity("task.started", {
675 taskId: "wf-p",
676 taskType: "local_workflow",
677 phases: [{ index: 0, title: "Only phase" }],
678 }),
679 activity("task.progress", {
680 taskId: "wf-p:wf:0",
681 parentAgentId: "wf-p",
682 status: "running",
683 phaseIndex: 0,
684 }),
685 activity("task.progress", {
686 taskId: "wf-p:wf:9",
687 parentAgentId: "wf-p",
688 status: "running",
689 phaseIndex: 9,
690 }),
691 ]),
692 });
693 const group = model.workflows[0]!;
694 const visible = [
695 ...group.phases.flatMap((phase) => phase.members),
696 ...group.unphasedMembers,
697 ].map((member) => member.id);
698 expect(visible).toContain("wf-p:wf:0");
699 expect(visible).toContain("wf-p:wf:9");
700 });
701});
702
703describe("coordinator settle cascade", () => {
704 it("members without their own terminal row settle when the coordinator does", () => {
705 const agents = fold([
706 activity("task.started", { taskId: "wf-1", taskType: "local_workflow" }),
707 activity("task.progress", {
708 taskId: "wf-1:wf:0",
709 title: "stalled member",
710 status: "running",
711 parentAgentId: "wf-1",
712 }),
713 activity("task.completed", {
714 taskId: "wf-1",
715 status: "completed",
716 taskType: "local_workflow",
717 }),
718 ]);
719 const member = agents.find((agent) => agent.id === "wf-1:wf:0");
720 expect(member?.status).toBe("completed");
721 expect(member?.completedAt).not.toBeNull();
722 });
723
724 it("a failed coordinator marks unfinished members interrupted, not completed", () => {
725 const agents = fold([
726 activity("task.started", { taskId: "wf-2", taskType: "local_workflow" }),
727 activity("task.progress", {
728 taskId: "wf-2:wf:0",
729 status: "running",
730 parentAgentId: "wf-2",
731 }),
732 activity("task.completed", { taskId: "wf-2", status: "failed", taskType: "local_workflow" }),
733 ]);
734 const member = agents.find((agent) => agent.id === "wf-2:wf:0");
735 expect(member?.status).toBe("interrupted");
736 });
737});
738
739describe("task type classification is a denylist", () => {
740 it("unknown agent-flavored types (local_agent, future names) join the roster", () => {
741 const agents = fold([
742 activity("task.started", {
743 taskId: "a1",
744 taskType: "local_agent",
745 title: "Math test 1",
746 role: "claude",
747 }),
748 activity("task.started", { taskId: "a2", taskType: "some_future_agent_kind", title: "X" }),
749 ]);
750 expect(agents.map((agent) => agent.id).toSorted()).toEqual(["a1", "a2"]);
751 });
752});
753
754describe("nested agents vs subagent shells", () => {
755 it("a nested agent (agentId + agent taskType) stays in the roster; its shells do not", () => {
756 const agents = fold([
757 activity("task.started", {
758 taskId: "nested-1",
759 taskType: "local_agent",
760 agentId: "parent-agent",
761 title: "Nested researcher",
762 }),
763 activity("task.started", {
764 taskId: "shell-1",
765 taskType: "local_bash",
766 agentId: "parent-agent",
767 title: "Nested sleep",
768 }),
769 ]);
770 expect(agents.map((agent) => agent.id)).toEqual(["nested-1"]);
771 });
772});
packages/client-runtime/src/state/subagentRuntime.ts created+924
...@@ -0,0 +1,924 @@
1/**
2 * Native-provider subagent observability: a tolerant fold over persisted
3 * task.* / tool.* thread activities into orchestration-v2-shaped subagent
4 * state, plus the source-neutral panel model every client renders.
5 *
6 * This module is deliberately legacy-bridge code. When orchestration-v2's
7 * subagent projection is available for a thread, deriveAgentPanelModel
8 * prefers it (see the v2Projection parameter) and the fold is skipped; when
9 * the v1 orchestrator is retired this file is deleted. Field names and
10 * transition semantics copy the v2 stack (#4779) exactly so that swap is
11 * mechanical.
12 *
13 * Invariants encoded here trace to shipped bugs in the prior PRs (#4220,
14 * #3650, #4662): reusable identity vs one-shot activations, idle as a real
15 * nonterminal state, provider-specific usage merges, first-write terminal
16 * timestamps, reactivation clearing terminal detail, and order-robust
17 * folding (completion can create an agent; a late start only fills
18 * metadata).
19 */
20import type { OrchestrationThreadActivity } from "@t3tools/contracts";
21
22export type RuntimeSubagentStatus =
23 | "pending"
24 | "running"
25 | "waiting"
26 | "idle"
27 | "completed"
28 | "failed"
29 | "cancelled"
30 | "interrupted";
31
32export interface SubagentUsage {
33 readonly totalTokens: number;
34 readonly inputTokens?: number;
35 readonly cachedInputTokens?: number;
36 readonly outputTokens?: number;
37 readonly reasoningOutputTokens?: number;
38 readonly toolUses?: number;
39 readonly durationMs?: number;
40}
41
42export interface SubagentActivityEntry {
43 readonly at: string;
44 readonly summary: string;
45}
46
47export interface SubagentWorkflowPhase {
48 readonly index: number;
49 readonly title: string;
50}
51
52export interface SubagentRunHandles {
53 readonly runId?: string;
54 readonly scriptPath?: string;
55 readonly transcriptDir?: string;
56 readonly sessionUrl?: string;
57}
58
59export interface RuntimeSubagent {
60 readonly id: string;
61 readonly kind: "subagent" | "workflow" | "workflow_agent";
62 readonly title: string;
63 readonly role: string | null;
64 readonly model: string | null;
65 readonly effort: string | null;
66 readonly status: RuntimeSubagentStatus;
67 readonly activationCount: number;
68 readonly usage: SubagentUsage | null;
69 readonly progress: string | null;
70 readonly lastToolName: string | null;
71 readonly result: string | null;
72 readonly error: string | null;
73 readonly outputFile: string | null;
74 readonly parentAgentId: string | null;
75 readonly agentIndex: number | null;
76 readonly phaseIndex: number | null;
77 readonly phaseTitle: string | null;
78 readonly attempt: number | null;
79 readonly workflowName: string | null;
80 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;
81 readonly runHandles: SubagentRunHandles | null;
82 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;
83 readonly startedAt: string | null;
84 readonly completedAt: string | null;
85 readonly updatedAt: string;
86}
87
88const TERMINAL_STATUSES: ReadonlySet<RuntimeSubagentStatus> = new Set([
89 "completed",
90 "failed",
91 "cancelled",
92 "interrupted",
93]);
94
95export function isTerminalSubagentStatus(status: RuntimeSubagentStatus): boolean {
96 return TERMINAL_STATUSES.has(status);
97}
98
99/** Active = the user may still need to care while it runs. Idle is settled-ish
100 * but resumable; waiting counts as active because it needs the user. */
101export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {
102 return status === "pending" || status === "running" || status === "waiting";
103}
104
105const RECENT_ACTIVITY_LIMIT = 6;
106const SUMMARY_CHAR_LIMIT = 180;
107const ROSTER_LIMIT = 100;
108
109/**
110 * True when this activity's payload does NOT belong on the Agents surface.
111 * Classification happens exactly once, server-side at ingestion
112 * (classifyTaskAgentKind → the persisted agentKind stamp); the client only
113 * reads it. Rows without a stamp — legacy threads, pre-stamp servers — are
114 * background by definition: they render in the ordinary work log, exactly
115 * as they did before this feature existed.
116 */
117export function isBackgroundTaskActivity(payload: Record<string, unknown>): boolean {
118 return payload.agentKind !== "agent";
119}
120
121function bounded(value: string): string {
122 return value.length <= SUMMARY_CHAR_LIMIT ? value : `${value.slice(0, SUMMARY_CHAR_LIMIT - 1)}…`;
123}
124
125/** Appends to the ring buffer, deduping consecutive identical summaries. */
126function appendActivity(
127 entries: ReadonlyArray<SubagentActivityEntry>,
128 at: string,
129 summary: string,
130): ReadonlyArray<SubagentActivityEntry> {
131 const boundedSummary = bounded(summary);
132 if (entries.length > 0 && entries[entries.length - 1]?.summary === boundedSummary) {
133 return entries;
134 }
135 const next = [...entries, { at, summary: boundedSummary }];
136 return next.length > RECENT_ACTIVITY_LIMIT ? next.slice(-RECENT_ACTIVITY_LIMIT) : next;
137}
138
139function asString(value: unknown): string | undefined {
140 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
141}
142
143function asCount(value: unknown): number | undefined {
144 return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
145}
146
147function asUsage(value: unknown): SubagentUsage | undefined {
148 if (typeof value !== "object" || value === null) {
149 return undefined;
150 }
151 const record = value as Record<string, unknown>;
152 const totalTokens = asCount(record.totalTokens);
153 if (totalTokens === undefined) {
154 return undefined;
155 }
156 const usage: {
157 totalTokens: number;
158 inputTokens?: number;
159 cachedInputTokens?: number;
160 outputTokens?: number;
161 reasoningOutputTokens?: number;
162 toolUses?: number;
163 durationMs?: number;
164 } = { totalTokens };
165 const inputTokens = asCount(record.inputTokens);
166 if (inputTokens !== undefined) usage.inputTokens = inputTokens;
167 const cachedInputTokens = asCount(record.cachedInputTokens);
168 if (cachedInputTokens !== undefined) usage.cachedInputTokens = cachedInputTokens;
169 const outputTokens = asCount(record.outputTokens);
170 if (outputTokens !== undefined) usage.outputTokens = outputTokens;
171 const reasoningOutputTokens = asCount(record.reasoningOutputTokens);
172 if (reasoningOutputTokens !== undefined) usage.reasoningOutputTokens = reasoningOutputTokens;
173 const toolUses = asCount(record.toolUses);
174 if (toolUses !== undefined) usage.toolUses = toolUses;
175 const durationMs = asCount(record.durationMs);
176 if (durationMs !== undefined) usage.durationMs = durationMs;
177 return usage;
178}
179
180/**
181 * Provider-specific usage merge (#4779 semantics, verbatim):
182 * - max-merge (Codex-style cumulative frames): field-wise maximum, idempotent
183 * under duplicate or late frames. Cumulative totals never shrink.
184 * - accumulate (Claude-style activation deltas): not needed at this layer —
185 * Claude's task_progress usage is itself cumulative per task, so the fold
186 * also max-merges. The distinction matters when v2 sums activations.
187 * Field-wise: a terminal payload carrying only totalTokens must not wipe a
188 * known breakdown.
189 */
190function mergeUsageMax(
191 current: SubagentUsage | null,
192 incoming: SubagentUsage | undefined,
193): SubagentUsage | null {
194 if (!incoming) {
195 return current;
196 }
197 if (!current) {
198 return incoming;
199 }
200 const pick = (a: number | undefined, b: number | undefined): number | undefined =>
201 a === undefined ? b : b === undefined ? a : Math.max(a, b);
202 const merged: {
203 totalTokens: number;
204 inputTokens?: number;
205 cachedInputTokens?: number;
206 outputTokens?: number;
207 reasoningOutputTokens?: number;
208 toolUses?: number;
209 durationMs?: number;
210 } = { totalTokens: Math.max(current.totalTokens, incoming.totalTokens) };
211 const inputTokens = pick(current.inputTokens, incoming.inputTokens);
212 if (inputTokens !== undefined) merged.inputTokens = inputTokens;
213 const cachedInputTokens = pick(current.cachedInputTokens, incoming.cachedInputTokens);
214 if (cachedInputTokens !== undefined) merged.cachedInputTokens = cachedInputTokens;
215 const outputTokens = pick(current.outputTokens, incoming.outputTokens);
216 if (outputTokens !== undefined) merged.outputTokens = outputTokens;
217 const reasoningOutputTokens = pick(current.reasoningOutputTokens, incoming.reasoningOutputTokens);
218 if (reasoningOutputTokens !== undefined) merged.reasoningOutputTokens = reasoningOutputTokens;
219 const toolUses = pick(current.toolUses, incoming.toolUses);
220 if (toolUses !== undefined) merged.toolUses = toolUses;
221 const durationMs = pick(current.durationMs, incoming.durationMs);
222 if (durationMs !== undefined) merged.durationMs = durationMs;
223 return merged;
224}
225
226interface MutableAgent {
227 id: string;
228 kind: RuntimeSubagent["kind"];
229 title: string;
230 role: string | null;
231 model: string | null;
232 effort: string | null;
233 status: RuntimeSubagentStatus;
234 activationCount: number;
235 usage: SubagentUsage | null;
236 progress: string | null;
237 lastToolName: string | null;
238 result: string | null;
239 error: string | null;
240 outputFile: string | null;
241 parentAgentId: string | null;
242 agentIndex: number | null;
243 phaseIndex: number | null;
244 phaseTitle: string | null;
245 attempt: number | null;
246 workflowName: string | null;
247 phases: ReadonlyArray<SubagentWorkflowPhase>;
248 runHandles: SubagentRunHandles | null;
249 recentActivity: ReadonlyArray<SubagentActivityEntry>;
250 startedAt: string | null;
251 completedAt: string | null;
252 updatedAt: string;
253}
254
255function kindFromPayload(
256 payload: Record<string, unknown>,
257 agentId: string,
258): RuntimeSubagent["kind"] {
259 if (asString(payload.taskType) === "local_workflow") {
260 return "workflow";
261 }
262 if (payload.parentAgentId !== undefined || agentId.includes(":wf:")) {
263 return "workflow_agent";
264 }
265 return "subagent";
266}
267
268/** Completion can create an agent (its start may have aged out of retention). */
269function getOrCreate(
270 agents: Map<string, MutableAgent>,
271 id: string,
272 payload: Record<string, unknown>,
273 at: string,
274): MutableAgent {
275 const existing = agents.get(id);
276 if (existing) {
277 return existing;
278 }
279 const created: MutableAgent = {
280 id,
281 kind: kindFromPayload(payload, id),
282 title: asString(payload.title) ?? asString(payload.detail) ?? id,
283 role: asString(payload.role) ?? null,
284 model: asString(payload.model) ?? null,
285 effort: asString(payload.effort) ?? null,
286 status: "pending",
287 activationCount: 0,
288 usage: null,
289 progress: null,
290 lastToolName: null,
291 result: null,
292 error: null,
293 outputFile: null,
294 parentAgentId: asString(payload.parentAgentId) ?? null,
295 agentIndex: asCount(payload.agentIndex) ?? null,
296 phaseIndex: asCount(payload.phaseIndex) ?? null,
297 phaseTitle: asString(payload.phaseTitle) ?? null,
298 attempt: asCount(payload.attempt) ?? null,
299 workflowName: asString(payload.workflowName) ?? null,
300 phases: [],
301 runHandles: null,
302 recentActivity: [],
303 startedAt: null,
304 completedAt: null,
305 updatedAt: at,
306 };
307 agents.set(id, created);
308 return created;
309}
310
311/** Metadata fill from any payload: never downgrades known values to null. */
312function fillMetadata(agent: MutableAgent, payload: Record<string, unknown>): void {
313 const title = asString(payload.title);
314 if (title) agent.title = title;
315 const role = asString(payload.role);
316 if (role) agent.role = role;
317 const model = asString(payload.model);
318 if (model) agent.model = model;
319 const effort = asString(payload.effort);
320 if (effort) agent.effort = effort;
321 const parentAgentId = asString(payload.parentAgentId);
322 if (parentAgentId) {
323 agent.parentAgentId = parentAgentId;
324 if (agent.kind === "subagent") agent.kind = "workflow_agent";
325 }
326 const workflowName = asString(payload.workflowName);
327 if (workflowName) agent.workflowName = workflowName;
328 if (asString(payload.taskType) === "local_workflow") agent.kind = "workflow";
329 const agentIndex = asCount(payload.agentIndex);
330 if (agentIndex !== undefined) agent.agentIndex = agentIndex;
331 const phaseIndex = asCount(payload.phaseIndex);
332 if (phaseIndex !== undefined) agent.phaseIndex = phaseIndex;
333 const phaseTitle = asString(payload.phaseTitle);
334 if (phaseTitle) agent.phaseTitle = phaseTitle;
335 const attempt = asCount(payload.attempt);
336 if (attempt !== undefined) {
337 // A new attempt on a workflow slot is a reactivation of the same
338 // identity: clear the previous attempt's terminal detail so the status
339 // transition (terminal → running, in applyStatus) reads as a fresh run.
340 // The activation bump lives ONLY in applyStatus — bumping here too
341 // counted every retry twice (review finding: two attempts read "run 3").
342 if (agent.attempt !== null && attempt > agent.attempt) {
343 agent.result = null;
344 agent.error = null;
345 agent.completedAt = null;
346 }
347 agent.attempt = attempt;
348 }
349 const outputFile = asString(payload.outputFile);
350 if (outputFile) agent.outputFile = outputFile;
351 if (Array.isArray(payload.phases)) {
352 const phases: SubagentWorkflowPhase[] = [];
353 for (const entry of payload.phases) {
354 if (typeof entry !== "object" || entry === null) continue;
355 const record = entry as Record<string, unknown>;
356 const index = asCount(record.index);
357 const phaseName = asString(record.title);
358 if (index !== undefined && phaseName) {
359 phases.push({ index, title: phaseName });
360 }
361 }
362 if (phases.length > 0) {
363 agent.phases = phases.slice().sort((a, b) => a.index - b.index);
364 }
365 }
366 if (typeof payload.runHandles === "object" && payload.runHandles !== null) {
367 const record = payload.runHandles as Record<string, unknown>;
368 const runHandles: {
369 runId?: string;
370 scriptPath?: string;
371 transcriptDir?: string;
372 sessionUrl?: string;
373 } = {};
374 const runId = asString(record.runId);
375 if (runId) runHandles.runId = runId;
376 const scriptPath = asString(record.scriptPath);
377 if (scriptPath) runHandles.scriptPath = scriptPath;
378 const transcriptDir = asString(record.transcriptDir);
379 if (transcriptDir) runHandles.transcriptDir = transcriptDir;
380 // Defense-in-depth: the adapter already sanitizes, but payloads are not
381 // schema-validated on the read path (shipped XSS lesson).
382 const sessionUrl = asString(record.sessionUrl);
383 if (sessionUrl && /^https?:\/\//i.test(sessionUrl)) runHandles.sessionUrl = sessionUrl;
384 if (Object.keys(runHandles).length > 0) {
385 agent.runHandles = { ...agent.runHandles, ...runHandles };
386 }
387 }
388}
389
390function applyStatus(agent: MutableAgent, status: RuntimeSubagentStatus, at: string): void {
391 const wasTerminal = isTerminalSubagentStatus(agent.status);
392 const isTerminal = isTerminalSubagentStatus(status);
393 if (wasTerminal && isTerminal) {
394 // Duplicate terminal events are idempotent: first write wins, timestamps
395 // don't slide.
396 return;
397 }
398 if ((wasTerminal || agent.status === "idle") && (status === "running" || status === "pending")) {
399 // Reactivation: same identity, new run. Clear the previous run's terminal
400 // detail so a live card never shows the prior run's output.
401 agent.activationCount += 1;
402 agent.result = null;
403 agent.error = null;
404 agent.completedAt = null;
405 if (status === "running") {
406 agent.startedAt = at;
407 }
408 }
409 if (status === "running" && agent.startedAt === null) {
410 agent.startedAt = at;
411 }
412 if (isTerminal && agent.completedAt === null) {
413 agent.completedAt = at;
414 }
415 agent.status = status;
416}
417
418// Map, not object literal: payloads aren't schema-validated on the read
419// path, so a status like "toString" must miss instead of resolving an
420// inherited Function through the prototype chain.
421const TASK_COMPLETED_STATUS: ReadonlyMap<string, RuntimeSubagentStatus> = new Map([
422 ["completed", "completed"],
423 ["failed", "failed"],
424 ["stopped", "interrupted"],
425]);
426
427const KNOWN_STATUSES: ReadonlySet<string> = new Set([
428 "pending",
429 "running",
430 "waiting",
431 "idle",
432 "completed",
433 "failed",
434 "cancelled",
435 "interrupted",
436]);
437
438function asRuntimeStatus(value: unknown): RuntimeSubagentStatus | undefined {
439 return typeof value === "string" && KNOWN_STATUSES.has(value)
440 ? (value as RuntimeSubagentStatus)
441 : undefined;
442}
443
444/**
445 * Folds a thread's persisted activities into subagent state. Tolerant by
446 * construction: malformed rows are skipped individually; unknown kinds are
447 * ignored. Pure — memoize by activity-list identity at the atom layer.
448 *
449 * sessionLive=false derives interruption: background tasks die with their
450 * provider session, so agents whose terminal rows were lost (server
451 * restart, crash) must not read as running forever (review finding: a dead
452 * session left a panel full of "Working" agents while the sidebar showed
453 * nothing). Idle is preserved — a resumable Codex child stays resumable.
454 */
455export function foldSubagentActivities(
456 activities: ReadonlyArray<OrchestrationThreadActivity>,
457 options?: { readonly sessionLive?: boolean },
458): ReadonlyArray<RuntimeSubagent> {
459 const agents = new Map<string, MutableAgent>();
460
461 for (const activity of activities) {
462 if (typeof activity.payload !== "object" || activity.payload === null) {
463 continue;
464 }
465 const payload = activity.payload as Record<string, unknown>;
466 const at = activity.createdAt;
467
468 switch (activity.kind) {
469 case "task.started": {
470 const taskId = asString(payload.taskId);
471 if (!taskId) break;
472 // Only real agents join the roster. Shells, monitors, and plan-mode
473 // tasks are background work — they render in the ordinary work log,
474 // not the Agents surface (a "Run 12s stall" shell is not a subagent).
475 if (isBackgroundTaskActivity(payload)) break;
476 const agent = getOrCreate(agents, taskId, payload, at);
477 fillMetadata(agent, payload);
478 // Order-robustness: a start row arriving after a terminal state is a
479 // late/out-of-order delivery and only fills metadata — it must not
480 // reopen the run. Reactivation comes exclusively from explicit
481 // status transitions (task.updated / progress status). Guard on the
482 // status itself, not activationCount: a task first seen via a
483 // terminal task.updated has zero activations but is still settled
484 // (review finding: a late start reopened a failed child).
485 if (agent.activationCount === 0 && !isTerminalSubagentStatus(agent.status)) {
486 agent.activationCount = 1;
487 agent.startedAt = agent.startedAt ?? at;
488 agent.status = "running";
489 } else if (agent.status === "idle") {
490 applyStatus(agent, "running", at);
491 }
492 const detail = asString(payload.detail);
493 if (detail && agent.title === agent.id) agent.title = detail;
494 agent.updatedAt = at;
495 break;
496 }
497 case "task.progress": {
498 const taskId = asString(payload.taskId);
499 if (!taskId) break;
500 // Membership is sticky per taskId: rows after the first (terminal
501 // rows often carry only taskId+status, no marker fields) inherit the
502 // first row's classification instead of being re-judged.
503 if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break;
504 const agent = getOrCreate(agents, taskId, payload, at);
505 fillMetadata(agent, payload);
506 if (agent.activationCount === 0) agent.activationCount = 1;
507 const explicitStatus = asRuntimeStatus(payload.status);
508 if (explicitStatus) {
509 applyStatus(agent, explicitStatus, at);
510 } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") {
511 applyStatus(agent, "running", at);
512 }
513 const summary = asString(payload.summary);
514 if (summary) {
515 agent.progress = bounded(summary);
516 agent.recentActivity = appendActivity(agent.recentActivity, at, summary);
517 }
518 const lastToolName = asString(payload.lastToolName);
519 if (lastToolName) {
520 agent.lastToolName = lastToolName;
521 if (!summary) {
522 agent.recentActivity = appendActivity(agent.recentActivity, at, `▸ ${lastToolName}`);
523 }
524 }
525 const error = asString(payload.error);
526 if (error) agent.error = bounded(error);
527 agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage));
528 agent.updatedAt = at;
529 break;
530 }
531 case "task.updated": {
532 const taskId = asString(payload.taskId);
533 if (!taskId) break;
534 // Membership is sticky per taskId: rows after the first (terminal
535 // rows often carry only taskId+status, no marker fields) inherit the
536 // first row's classification instead of being re-judged.
537 if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break;
538 const agent = getOrCreate(agents, taskId, payload, at);
539 fillMetadata(agent, payload);
540 // A task first seen via task.updated (start row aged out) has run at
541 // least once — zero activations would misreport "run 0" and let a
542 // later start row treat it as never-started (review finding).
543 if (agent.activationCount === 0) agent.activationCount = 1;
544 const wasTerminal = isTerminalSubagentStatus(agent.status);
545 const status = asRuntimeStatus(payload.status);
546 if (status) applyStatus(agent, status, at);
547 const error = asString(payload.error);
548 if (error) agent.error = bounded(error);
549 // Provider end time beats ingestion time for the transition that
550 // actually settled the run (applyStatus fills completedAt with the
551 // activity timestamp first, so check the transition, not null).
552 const endedAt = asString(payload.endedAt);
553 if (endedAt && !wasTerminal && isTerminalSubagentStatus(agent.status)) {
554 agent.completedAt = endedAt;
555 }
556 agent.updatedAt = at;
557 break;
558 }
559 case "task.completed": {
560 const taskId = asString(payload.taskId);
561 if (!taskId) break;
562 // Membership is sticky per taskId: rows after the first (terminal
563 // rows often carry only taskId+status, no marker fields) inherit the
564 // first row's classification instead of being re-judged.
565 if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break;
566 const agent = getOrCreate(agents, taskId, payload, at);
567 fillMetadata(agent, payload);
568 if (agent.activationCount === 0) agent.activationCount = 1;
569 // Already-terminal: status and timestamps are frozen (first write
570 // wins, duplicates must not slide them) but the completion still
571 // ENRICHES — Claude commonly emits terminal task.updated before
572 // task.completed, and the completion carries the result summary and
573 // final usage the update lacked (review finding: the early return
574 // dropped both). Fill-if-missing keeps duplicate completions from
575 // replacing the first result.
576 const summary = asString(payload.summary) ?? asString(payload.detail);
577 if (isTerminalSubagentStatus(agent.status)) {
578 if (summary) {
579 if (agent.status === "failed") {
580 agent.error = agent.error ?? bounded(summary);
581 } else {
582 agent.result = agent.result ?? bounded(summary);
583 }
584 }
585 agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage));
586 break;
587 }
588 const status = TASK_COMPLETED_STATUS.get(asString(payload.status) ?? "") ?? "completed";
589 applyStatus(agent, status, at);
590 if (summary) {
591 if (status === "failed") {
592 agent.error = agent.error ?? bounded(summary);
593 } else {
594 agent.result = bounded(summary);
595 }
596 }
597 agent.usage = mergeUsageMax(agent.usage, asUsage(payload.typedUsage));
598 agent.updatedAt = at;
599 break;
600 }
601 case "tool.progress": {
602 // Agent-owned heartbeat: "what it's doing right now".
603 const taskId = asString(payload.taskId);
604 if (!taskId) break;
605 const agent = agents.get(taskId);
606 if (!agent) break;
607 const toolName = asString(payload.toolName);
608 if (toolName) {
609 agent.lastToolName = toolName;
610 agent.recentActivity = appendActivity(agent.recentActivity, at, `▸ ${toolName}`);
611 }
612 agent.updatedAt = at;
613 break;
614 }
615 default:
616 break;
617 }
618 }
619
620 // Consistency pass: when a workflow coordinator has settled, members that
621 // never received their own terminal row cannot still be in-flight — the
622 // run is over. Cascade the coordinator's outcome so stalled member rows
623 // don't read as working forever (live-test finding: statuses drifted
624 // whenever member terminal rows were lost or never emitted).
625 for (const agent of agents.values()) {
626 if (agent.kind !== "workflow" || !isTerminalSubagentStatus(agent.status)) {
627 continue;
628 }
629 for (const member of agents.values()) {
630 if (member.parentAgentId !== agent.id) {
631 continue;
632 }
633 if (isTerminalSubagentStatus(member.status) || member.status === "idle") {
634 continue;
635 }
636 member.status = agent.status === "completed" ? "completed" : "interrupted";
637 member.completedAt = member.completedAt ?? agent.completedAt ?? agent.updatedAt;
638 member.updatedAt = agent.updatedAt;
639 }
640 }
641
642 // Session death orphans every live agent: no process remains to finish
643 // them. Mirrors the server-side liveness registry clearing on
644 // session.exited, so panel and sidebar can never disagree.
645 if (options?.sessionLive === false) {
646 for (const agent of agents.values()) {
647 if (isActiveSubagentStatus(agent.status)) {
648 agent.status = "interrupted";
649 agent.completedAt = agent.completedAt ?? agent.updatedAt;
650 }
651 }
652 }
653
654 let roster = Array.from(agents.values());
655 if (roster.length > ROSTER_LIMIT) {
656 // Prefer live, then waiting/idle, then newest settled.
657 const rank = (agent: MutableAgent): number =>
658 isActiveSubagentStatus(agent.status) ? 0 : agent.status === "idle" ? 1 : 2;
659 roster = roster
660 .slice()
661 .sort((a, b) => rank(a) - rank(b) || b.updatedAt.localeCompare(a.updatedAt))
662 .slice(0, ROSTER_LIMIT);
663 }
664
665 return roster.map((agent) => ({ ...agent }));
666}
667
668export interface AgentPanelWorkflowGroup {
669 readonly workflow: RuntimeSubagent;
670 readonly phases: ReadonlyArray<{
671 readonly index: number;
672 readonly title: string;
673 readonly members: ReadonlyArray<RuntimeSubagent>;
674 /** done = every member settled (success or error); running = any active. */
675 readonly state: "pending" | "running" | "done";
676 readonly activeCount: number;
677 readonly settledCount: number;
678 }>;
679 /** Members with no resolvable phase (orphans render under the workflow). */
680 readonly unphasedMembers: ReadonlyArray<RuntimeSubagent>;
681}
682
683export interface AgentPanelModel {
684 readonly workflows: ReadonlyArray<AgentPanelWorkflowGroup>;
685 readonly directAgents: ReadonlyArray<RuntimeSubagent>;
686 readonly runningCount: number;
687 readonly waitingCount: number;
688 readonly idleCount: number;
689 readonly settledCount: number;
690 readonly totalTokens: number;
691 readonly hasAgents: boolean;
692 readonly liveCount: number;
693}
694
695const EMPTY_PANEL_MODEL: AgentPanelModel = {
696 workflows: [],
697 directAgents: [],
698 runningCount: 0,
699 waitingCount: 0,
700 idleCount: 0,
701 settledCount: 0,
702 totalTokens: 0,
703 hasAgents: false,
704 liveCount: 0,
705};
706
707export function emptyAgentPanelModel(): AgentPanelModel {
708 return EMPTY_PANEL_MODEL;
709}
710
711/**
712 * Source-neutral view model. When the orchestration-v2 subagent projection
713 * exists for the thread, pass it as v2Projection and it wins outright — the
714 * two sources are never merged (duplicate-agents failure mode). Until v2
715 * lands, callers pass null and the native fold output is used.
716 */
717export function deriveAgentPanelModel({
718 agents,
719 v2Projection,
720}: {
721 readonly agents: ReadonlyArray<RuntimeSubagent>;
722 readonly v2Projection?: ReadonlyArray<RuntimeSubagent> | null;
723}): AgentPanelModel {
724 const source = v2Projection ?? agents;
725 if (source.length === 0) {
726 return EMPTY_PANEL_MODEL;
727 }
728
729 const workflows = source.filter((agent) => agent.kind === "workflow");
730 const workflowIds = new Set(workflows.map((workflow) => workflow.id));
731 const members = new Map<string, RuntimeSubagent[]>();
732 const direct: RuntimeSubagent[] = [];
733
734 for (const agent of source) {
735 if (agent.kind === "workflow") {
736 continue;
737 }
738 if (agent.parentAgentId !== null && workflowIds.has(agent.parentAgentId)) {
739 const list = members.get(agent.parentAgentId) ?? [];
740 list.push(agent);
741 members.set(agent.parentAgentId, list);
742 } else {
743 // Orphaned members (coordinator aged out) fall back to the direct list.
744 direct.push(agent);
745 }
746 }
747
748 const workflowGroups: AgentPanelWorkflowGroup[] = workflows.map((workflow) => {
749 const workflowMembers = members.get(workflow.id) ?? [];
750 const knownPhases =
751 workflow.phases.length > 0
752 ? workflow.phases
753 : (() => {
754 const derived = new Map<number, string>();
755 for (const member of workflowMembers) {
756 if (member.phaseIndex !== null && !derived.has(member.phaseIndex)) {
757 derived.set(
758 member.phaseIndex,
759 member.phaseTitle ?? `Phase ${member.phaseIndex + 1}`,
760 );
761 }
762 }
763 return Array.from(derived.entries())
764 .map(([index, title]) => ({ index, title }))
765 .slice()
766 .sort((a, b) => a.index - b.index);
767 })();
768
769 const knownPhaseIndices = new Set(knownPhases.map((phase) => phase.index));
770 const phases = knownPhases.map((phase) => {
771 const phaseMembers = workflowMembers
772 .filter((member) => member.phaseIndex === phase.index)
773 .slice()
774 .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
775 const activeCount = phaseMembers.filter(
776 // Idle members count as active for phase-liveness: a resumable Codex
777 // member has not finished the phase.
778 (member) => isActiveSubagentStatus(member.status) || member.status === "idle",
779 ).length;
780 const settledCount = phaseMembers.filter((member) =>
781 isTerminalSubagentStatus(member.status),
782 ).length;
783 const state: "pending" | "running" | "done" =
784 phaseMembers.length === 0
785 ? "pending"
786 : activeCount > 0
787 ? "running"
788 : settledCount === phaseMembers.length
789 ? "done"
790 : "pending";
791 return {
792 index: phase.index,
793 title: phase.title,
794 members: phaseMembers,
795 state,
796 activeCount,
797 settledCount,
798 };
799 });
800
801 // Unknown phase indices land here too — a member must never vanish just
802 // because its phase row was lost (review finding).
803 const unphasedMembers = workflowMembers
804 .filter((member) => member.phaseIndex === null || !knownPhaseIndices.has(member.phaseIndex))
805 .slice()
806 .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
807
808 return { workflow, phases, unphasedMembers };
809 });
810
811 let runningCount = 0;
812 let waitingCount = 0;
813 let idleCount = 0;
814 let settledCount = 0;
815 let totalTokens = 0;
816 for (const agent of source) {
817 if (agent.status === "running" || agent.status === "pending") runningCount += 1;
818 else if (agent.status === "waiting") waitingCount += 1;
819 else if (agent.status === "idle") idleCount += 1;
820 else settledCount += 1;
821 // Workflow coordinators aggregate member usage upstream in some providers;
822 // avoid double counting by only summing leaf agents when members exist.
823 if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) {
824 totalTokens += agent.usage?.totalTokens ?? 0;
825 }
826 }
827
828 return {
829 workflows: workflowGroups,
830 directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
831 runningCount,
832 waitingCount,
833 idleCount,
834 settledCount,
835 totalTokens,
836 hasAgents: true,
837 liveCount: runningCount + waitingCount,
838 };
839}
840
841/**
842 * Members ordered by urgency for the capped inline workflow card: running and
843 * failed first, then waiting, then most recently updated.
844 */
845export function workflowCardMembers(
846 group: AgentPanelWorkflowGroup,
847 limit: number,
848): { readonly visible: ReadonlyArray<RuntimeSubagent>; readonly overflow: number } {
849 const all = [...group.phases.flatMap((phase) => phase.members), ...group.unphasedMembers];
850 const urgency = (agent: RuntimeSubagent): number => {
851 if (agent.status === "failed") return 0;
852 if (agent.status === "running") return 1;
853 if (agent.status === "waiting") return 2;
854 return 3;
855 };
856 const ordered = all
857 .slice()
858 .sort((a, b) => urgency(a) - urgency(b) || b.updatedAt.localeCompare(a.updatedAt));
859 return {
860 visible: ordered.slice(0, limit),
861 overflow: Math.max(0, ordered.length - limit),
862 };
863}
864
865/** Kinds the timeline should not render as generic rows (fold input only). */
866export function isSubagentActivityKind(kind: string): boolean {
867 return (
868 kind === "task.started" ||
869 kind === "task.progress" ||
870 kind === "task.updated" ||
871 kind === "task.completed" ||
872 kind === "tool.progress"
873 );
874}
875
876/**
877 * Quiet-timeline guarantee: tool rows attributed to an owning agent belong in
878 * the Agents surface, not the parent chat. Unattributed rows must stay.
879 */
880export function isAgentAttributedToolActivity(activity: OrchestrationThreadActivity): boolean {
881 if (typeof activity.payload !== "object" || activity.payload === null) {
882 return false;
883 }
884 const payload = activity.payload as Record<string, unknown>;
885 return typeof payload.agentId === "string" && payload.agentId.trim().length > 0;
886}
887
888/** Timeline-bypassing synthesized rows (Codex children, workflow members). */
889export function isTimelineBypassActivity(activity: OrchestrationThreadActivity): boolean {
890 if (typeof activity.payload !== "object" || activity.payload === null) {
891 return false;
892 }
893 return (activity.payload as Record<string, unknown>).timelineBypass === true;
894}
895
896/**
897 * Compact model chip text: strips vendor prefixes/date-or-context suffixes
898 * ("claude-sonnet-5[1m]" → "sonnet-5[1m]", "claude-opus-4-20250514" →
899 * "opus-4"). Unknown ids pass through untouched; effort appends as "· high".
900 */
901export function formatSubagentModelLabel(
902 model: string | null,
903 effort: string | null,
904): string | null {
905 if (!model) {
906 return null;
907 }
908 const compact = model
909 .replace(/^claude-/, "")
910 .replace(/-\d{8}$/, "")
911 .replace(/-latest$/, "");
912 return effort ? `${compact} · ${effort}` : compact;
913}
914
915export function formatSubagentTokenCount(totalTokens: number): string {
916 if (totalTokens < 1000) {
917 return `${totalTokens}`;
918 }
919 if (totalTokens < 1_000_000) {
920 const value = totalTokens / 1000;
921 return `${value >= 100 ? Math.round(value) : value.toFixed(1)}k`;
922 }
923 return `${(totalTokens / 1_000_000).toFixed(1)}M`;
924}
packages/contracts/src/orchestration.ts+59
...@@ -24,6 +24,7 @@ import { ProviderInstanceId } from "./providerInstance.ts";...@@ -24,6 +24,7 @@ import { ProviderInstanceId } from "./providerInstance.ts";
2424
25export const ORCHESTRATION_WS_METHODS = {25export const ORCHESTRATION_WS_METHODS = {
26 dispatchCommand: "orchestration.dispatchCommand",26 dispatchCommand: "orchestration.dispatchCommand",
27 getWorkflowScript: "orchestration.getWorkflowScript",
27 getTurnDiff: "orchestration.getTurnDiff",28 getTurnDiff: "orchestration.getTurnDiff",
28 getFullThreadDiff: "orchestration.getFullThreadDiff",29 getFullThreadDiff: "orchestration.getFullThreadDiff",
29 searchThreads: "orchestration.searchThreads",30 searchThreads: "orchestration.searchThreads",
...@@ -438,6 +439,12 @@ export const OrchestrationThreadShell = Schema.Struct({...@@ -438,6 +439,12 @@ export const OrchestrationThreadShell = Schema.Struct({
438 hasPendingApprovals: Schema.Boolean,439 hasPendingApprovals: Schema.Boolean,
439 hasPendingUserInput: Schema.Boolean,440 hasPendingUserInput: Schema.Boolean,
440 hasActionableProposedPlan: Schema.Boolean,441 hasActionableProposedPlan: Schema.Boolean,
442 /**
443 * Native background work alive after the turn settles: "working" while
444 * subagents/workflows run, "monitoring" when watch loops are the only
445 * live work. Optional so old servers/clients interop; absent = none.
446 */
447 backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))),
441});448});
442export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;449export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
443450
...@@ -1464,11 +1471,63 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({...@@ -1464,11 +1471,63 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({
1464});1471});
1465export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type;1472export type OrchestrationSearchThreadsResult = typeof OrchestrationSearchThreadsResult.Type;
14661473
1474export const OrchestrationGetWorkflowScriptInput = Schema.Struct({
1475 threadId: ThreadId,
1476 /** Absolute path from the workflow's runHandles.scriptPath. The server
1477 * re-derives containment; the client value is a hint, never trusted. */
1478 scriptPath: TrimmedNonEmptyString,
1479});
1480export type OrchestrationGetWorkflowScriptInput = typeof OrchestrationGetWorkflowScriptInput.Type;
1481
1482export const OrchestrationGetWorkflowScriptResult = Schema.Struct({
1483 scriptPath: TrimmedNonEmptyString,
1484 contents: Schema.String,
1485 truncated: Schema.Boolean,
1486});
1487export type OrchestrationGetWorkflowScriptResult = typeof OrchestrationGetWorkflowScriptResult.Type;
1488
1489const WORKFLOW_SCRIPT_ERROR_MESSAGES = {
1490 "invalid-path": "Workflow scripts must be absolute .js paths.",
1491 "root-unavailable": "Script root unavailable.",
1492 "not-found": "Script not found.",
1493 "outside-root": "Script path is outside the workflow scripts root.",
1494 "not-js": "Resolved script is not a .js file.",
1495 "not-regular-file": "Script is not a regular file.",
1496 "changed-during-read": "Script changed between resolution and open.",
1497 "read-failed": "Script read failed.",
1498} as const;
1499
1500export class OrchestrationGetWorkflowScriptError extends Schema.TaggedErrorClass<OrchestrationGetWorkflowScriptError>()(
1501 "OrchestrationGetWorkflowScriptError",
1502 {
1503 reason: Schema.Literals([
1504 "invalid-path",
1505 "root-unavailable",
1506 "not-found",
1507 "outside-root",
1508 "not-js",
1509 "not-regular-file",
1510 "changed-during-read",
1511 "read-failed",
1512 ]),
1513 scriptPath: Schema.String,
1514 cause: Schema.optional(Schema.Defect()),
1515 },
1516) {
1517 override get message(): string {
1518 return WORKFLOW_SCRIPT_ERROR_MESSAGES[this.reason];
1519 }
1520}
1521
1467export const OrchestrationRpcSchemas = {1522export const OrchestrationRpcSchemas = {
1468 dispatchCommand: {1523 dispatchCommand: {
1469 input: ClientOrchestrationCommand,1524 input: ClientOrchestrationCommand,
1470 output: DispatchResult,1525 output: DispatchResult,
1471 },1526 },
1527 getWorkflowScript: {
1528 input: OrchestrationGetWorkflowScriptInput,
1529 output: OrchestrationGetWorkflowScriptResult,
1530 },
1472 getTurnDiff: {1531 getTurnDiff: {
1473 input: OrchestrationGetTurnDiffInput,1532 input: OrchestrationGetTurnDiffInput,
1474 output: OrchestrationGetTurnDiffResult,1533 output: OrchestrationGetTurnDiffResult,
packages/contracts/src/providerRuntime.test.ts+20-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { ProviderRuntimeEvent } from "./providerRuntime.ts";4import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.ts";
55
6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
77
...@@ -182,3 +182,22 @@ describe("ProviderRuntimeEvent", () => {...@@ -182,3 +182,22 @@ describe("ProviderRuntimeEvent", () => {
182 expect(parsed.payload.usage.usedTokens).toBe(31251);182 expect(parsed.payload.usage.usedTokens).toBe(31251);
183 });183 });
184});184});
185
186describe("classifyTaskAgentKind", () => {
187 it("classifies agent-flavored, watch-loop, and inert types", () => {
188 expect(classifyTaskAgentKind({ taskType: "local_agent" })).toBe("agent");
189 expect(classifyTaskAgentKind({ taskType: "local_workflow" })).toBe("agent");
190 expect(classifyTaskAgentKind({ taskType: undefined })).toBe("agent");
191 expect(classifyTaskAgentKind({ taskType: "brand_new_agent_type" })).toBe("agent");
192 expect(classifyTaskAgentKind({ taskType: "local_bash" })).toBe("background");
193 expect(classifyTaskAgentKind({ taskType: "monitor" })).toBe("background");
194 expect(classifyTaskAgentKind({ taskType: "plan" })).toBe("background");
195 });
196
197 it("agent-owned tasks are background unless themselves agent-flavored", () => {
198 expect(classifyTaskAgentKind({ taskType: "local_bash", agentId: "owner" })).toBe("background");
199 expect(classifyTaskAgentKind({ taskType: undefined, agentId: "owner" })).toBe("background");
200 // Nested agent: outlives its parent, stays in the roster.
201 expect(classifyTaskAgentKind({ taskType: "local_agent", agentId: "owner" })).toBe("agent");
202 });
203});
packages/contracts/src/providerRuntime.ts+174-1
...@@ -176,6 +176,7 @@ const ProviderRuntimeEventType = Schema.Literals([...@@ -176,6 +176,7 @@ const ProviderRuntimeEventType = Schema.Literals([
176 "user-input.resolved",176 "user-input.resolved",
177 "task.started",177 "task.started",
178 "task.progress",178 "task.progress",
179 "task.updated",
179 "task.completed",180 "task.completed",
180 "hook.started",181 "hook.started",
181 "hook.progress",182 "hook.progress",
...@@ -226,6 +227,7 @@ const UserInputRequestedType = Schema.Literal("user-input.requested");...@@ -226,6 +227,7 @@ const UserInputRequestedType = Schema.Literal("user-input.requested");
226const UserInputResolvedType = Schema.Literal("user-input.resolved");227const UserInputResolvedType = Schema.Literal("user-input.resolved");
227const TaskStartedType = Schema.Literal("task.started");228const TaskStartedType = Schema.Literal("task.started");
228const TaskProgressType = Schema.Literal("task.progress");229const TaskProgressType = Schema.Literal("task.progress");
230const TaskUpdatedType = Schema.Literal("task.updated");
229const TaskCompletedType = Schema.Literal("task.completed");231const TaskCompletedType = Schema.Literal("task.completed");
230const HookStartedType = Schema.Literal("hook.started");232const HookStartedType = Schema.Literal("hook.started");
231const HookProgressType = Schema.Literal("hook.progress");233const HookProgressType = Schema.Literal("hook.progress");
...@@ -407,6 +409,13 @@ export const ItemLifecyclePayload = Schema.Struct({...@@ -407,6 +409,13 @@ export const ItemLifecyclePayload = Schema.Struct({
407 title: Schema.optional(TrimmedNonEmptyStringSchema),409 title: Schema.optional(TrimmedNonEmptyStringSchema),
408 detail: Schema.optional(TrimmedNonEmptyStringSchema),410 detail: Schema.optional(TrimmedNonEmptyStringSchema),
409 data: Schema.optional(Schema.Unknown),411 data: Schema.optional(Schema.Unknown),
412 /**
413 * Owning agent when this item ran inside a subagent (resolved from the
414 * SDK's parent_tool_use_id). Clients re-home attributed items out of the
415 * main timeline and into the owning agent's Agents-surface row.
416 */
417 agentId: Schema.optional(TrimmedNonEmptyStringSchema),
418 parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema),
410});419});
411export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type;420export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type;
412421
...@@ -459,27 +468,180 @@ const UserInputResolvedPayload = Schema.Struct({...@@ -459,27 +468,180 @@ const UserInputResolvedPayload = Schema.Struct({
459});468});
460export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type;469export type UserInputResolvedPayload = typeof UserInputResolvedPayload.Type;
461470
471/**
472 * Typed per-task usage rollup. Field names match the orchestration-v2 subagent
473 * usage vocabulary (#4779) so the eventual migration is a rename, not a remap.
474 * Claude reports per-activation deltas; Codex reports cumulative totals — the
475 * merge strategy is provider-specific and lives in client-runtime.
476 */
477export const RuntimeTaskUsage = Schema.Struct({
478 totalTokens: NonNegativeInt,
479 inputTokens: Schema.optional(NonNegativeInt),
480 cachedInputTokens: Schema.optional(NonNegativeInt),
481 outputTokens: Schema.optional(NonNegativeInt),
482 reasoningOutputTokens: Schema.optional(NonNegativeInt),
483 toolUses: Schema.optional(NonNegativeInt),
484 durationMs: Schema.optional(NonNegativeInt),
485});
486export type RuntimeTaskUsage = typeof RuntimeTaskUsage.Type;
487
488export const TaskWorkflowPhase = Schema.Struct({
489 index: NonNegativeInt,
490 title: TrimmedNonEmptyStringSchema,
491});
492export type TaskWorkflowPhase = typeof TaskWorkflowPhase.Type;
493
494export const TaskRunHandles = Schema.Struct({
495 runId: Schema.optional(TrimmedNonEmptyStringSchema),
496 scriptPath: Schema.optional(TrimmedNonEmptyStringSchema),
497 transcriptDir: Schema.optional(TrimmedNonEmptyStringSchema),
498 /** Only http/https URLs may be stored here — sanitized at the adapter. */
499 sessionUrl: Schema.optional(TrimmedNonEmptyStringSchema),
500});
501export type TaskRunHandles = typeof TaskRunHandles.Type;
502
503/**
504 * Watch-loop task types: Monitor-tool tasks plus background shells (a shell
505 * that outlives its turn is in practice a watch loop). Canonical single copy —
506 * the server liveness registry, ingestion's agentKind stamp, and the client
507 * fold's legacy fallback all classify with these sets.
508 */
509export const MONITOR_TASK_TYPES: ReadonlySet<string> = new Set([
510 "monitor",
511 "monitor_mcp",
512 "local_bash",
513 "shell",
514]);
515/** Task types that are neither agents nor watch loops (plan-mode bookkeeping). */
516export const INERT_TASK_TYPES: ReadonlySet<string> = new Set(["plan", "dream"]);
517
518/**
519 * Agent-vs-background classification, stamped by ingestion as `agentKind` so
520 * persisted rows are self-describing. A deliberate denylist: the SDK's
521 * agent-flavored type names drift (subagent, local_agent, local_workflow, …)
522 * and an allowlist silently dropped real subagents when "local_agent"
523 * appeared. A task launched from inside a subagent (agentId set) is
524 * agent-internal background work UNLESS it is itself agent-flavored — a
525 * nested agent can outlive its parent and stays in the roster.
526 */
527export function classifyTaskAgentKind(input: {
528 readonly taskType?: string | undefined;
529 readonly agentId?: string | undefined;
530}): "agent" | "background" {
531 const { taskType, agentId } = input;
532 const nonAgentType =
533 taskType !== undefined && (MONITOR_TASK_TYPES.has(taskType) || INERT_TASK_TYPES.has(taskType));
534 if (agentId !== undefined && agentId.trim().length > 0) {
535 return taskType === undefined || nonAgentType ? "background" : "agent";
536 }
537 return nonAgentType ? "background" : "agent";
538}
539
540/**
541 * Optional agent-identity linkage carried on every task lifecycle payload.
542 * Repeated on progress and terminal rows (not just start) so client folds can
543 * reconstruct an agent even when its start row aged out of activity retention.
544 * All fields optional: old emitters and old rows decode unchanged.
545 */
546const taskAgentLinkageFields = {
547 /** SDK task_type (subagent/shell/monitor/local_workflow/…), repeated on
548 * every row so folds can classify without the start row. */
549 taskType: Schema.optional(TrimmedNonEmptyStringSchema),
550 /**
551 * Server-stamped classification (classifyTaskAgentKind at ingestion).
552 * Clients trust this stamp outright; rows without it (legacy, pre-stamp)
553 * fall back to client-side heuristics.
554 */
555 agentKind: Schema.optional(Schema.Literals(["agent", "background"])),
556 /**
557 * Owning agent when the task itself was launched from inside a subagent
558 * (e.g. a subagent's background shell). Clients treat such tasks as
559 * agent-internal and keep them out of the parent work log.
560 */
561 agentId: Schema.optional(TrimmedNonEmptyStringSchema),
562 title: Schema.optional(TrimmedNonEmptyStringSchema),
563 role: Schema.optional(TrimmedNonEmptyStringSchema),
564 model: Schema.optional(TrimmedNonEmptyStringSchema),
565 /** Reasoning effort when known (e.g. "high"). Open string: provider vocabularies differ. */
566 effort: Schema.optional(TrimmedNonEmptyStringSchema),
567 toolUseId: Schema.optional(TrimmedNonEmptyStringSchema),
568 parentAgentId: Schema.optional(TrimmedNonEmptyStringSchema),
569 workflowName: Schema.optional(TrimmedNonEmptyStringSchema),
570 agentIndex: Schema.optional(NonNegativeInt),
571 phaseIndex: Schema.optional(NonNegativeInt),
572 phaseTitle: Schema.optional(TrimmedNonEmptyStringSchema),
573 phases: Schema.optional(Schema.Array(TaskWorkflowPhase)),
574 attempt: Schema.optional(NonNegativeInt),
575 runHandles: Schema.optional(TaskRunHandles),
576 outputFile: Schema.optional(TrimmedNonEmptyStringSchema),
577 /** Codex agent hierarchy path, e.g. "/root/marlow". */
578 agentPath: Schema.optional(TrimmedNonEmptyStringSchema),
579 /**
580 * Set on provider-synthesized child-agent events (Codex) whose activity
581 * belongs in the Agents surface, never the parent timeline.
582 */
583 timelineBypass: Schema.optional(Schema.Boolean),
584} as const;
585
586export const TaskAgentLinkage = Schema.Struct(taskAgentLinkageFields);
587export type TaskAgentLinkage = typeof TaskAgentLinkage.Type;
588
462const TaskStartedPayload = Schema.Struct({589const TaskStartedPayload = Schema.Struct({
463 taskId: RuntimeTaskId,590 taskId: RuntimeTaskId,
464 description: Schema.optional(TrimmedNonEmptyStringSchema),591 description: Schema.optional(TrimmedNonEmptyStringSchema),
465 taskType: Schema.optional(TrimmedNonEmptyStringSchema),592 ...taskAgentLinkageFields,
466});593});
467export type TaskStartedPayload = typeof TaskStartedPayload.Type;594export type TaskStartedPayload = typeof TaskStartedPayload.Type;
468595
596export const RuntimeTaskStatus = Schema.Literals([
597 "pending",
598 "running",
599 "waiting",
600 "idle",
601 "completed",
602 "failed",
603 "cancelled",
604 "interrupted",
605]);
606export type RuntimeTaskStatus = typeof RuntimeTaskStatus.Type;
607
469const TaskProgressPayload = Schema.Struct({608const TaskProgressPayload = Schema.Struct({
470 taskId: RuntimeTaskId,609 taskId: RuntimeTaskId,
471 description: TrimmedNonEmptyStringSchema,610 description: TrimmedNonEmptyStringSchema,
472 summary: Schema.optional(TrimmedNonEmptyStringSchema),611 summary: Schema.optional(TrimmedNonEmptyStringSchema),
473 usage: Schema.optional(Schema.Unknown),612 usage: Schema.optional(Schema.Unknown),
613 typedUsage: Schema.optional(RuntimeTaskUsage),
474 lastToolName: Schema.optional(TrimmedNonEmptyStringSchema),614 lastToolName: Schema.optional(TrimmedNonEmptyStringSchema),
615 /** Present on synthesized member/child progress rows that carry state. */
616 status: Schema.optional(RuntimeTaskStatus),
617 error: Schema.optional(TrimmedNonEmptyStringSchema),
618 ...taskAgentLinkageFields,
475});619});
476export type TaskProgressPayload = typeof TaskProgressPayload.Type;620export type TaskProgressPayload = typeof TaskProgressPayload.Type;
477621
622/**
623 * Non-terminal status patch (from the Claude SDK's task_updated, which main
624 * previously dropped). killed→cancelled and paused→idle are mapped at the
625 * adapter so the wire only carries the shared vocabulary.
626 */
627const TaskUpdatedPayload = Schema.Struct({
628 taskId: RuntimeTaskId,
629 status: Schema.optional(RuntimeTaskStatus),
630 description: Schema.optional(TrimmedNonEmptyStringSchema),
631 error: Schema.optional(TrimmedNonEmptyStringSchema),
632 endedAt: Schema.optional(IsoDateTime),
633 isBackgrounded: Schema.optional(Schema.Boolean),
634 ...taskAgentLinkageFields,
635});
636export type TaskUpdatedPayload = typeof TaskUpdatedPayload.Type;
637
478const TaskCompletedPayload = Schema.Struct({638const TaskCompletedPayload = Schema.Struct({
479 taskId: RuntimeTaskId,639 taskId: RuntimeTaskId,
480 status: Schema.Literals(["completed", "failed", "stopped"]),640 status: Schema.Literals(["completed", "failed", "stopped"]),
481 summary: Schema.optional(TrimmedNonEmptyStringSchema),641 summary: Schema.optional(TrimmedNonEmptyStringSchema),
482 usage: Schema.optional(Schema.Unknown),642 usage: Schema.optional(Schema.Unknown),
643 typedUsage: Schema.optional(RuntimeTaskUsage),
644 ...taskAgentLinkageFields,
483});645});
484export type TaskCompletedPayload = typeof TaskCompletedPayload.Type;646export type TaskCompletedPayload = typeof TaskCompletedPayload.Type;
485647
...@@ -513,6 +675,9 @@ const ToolProgressPayload = Schema.Struct({...@@ -513,6 +675,9 @@ const ToolProgressPayload = Schema.Struct({
513 toolName: Schema.optional(TrimmedNonEmptyStringSchema),675 toolName: Schema.optional(TrimmedNonEmptyStringSchema),
514 summary: Schema.optional(TrimmedNonEmptyStringSchema),676 summary: Schema.optional(TrimmedNonEmptyStringSchema),
515 elapsedSeconds: Schema.optional(Schema.Number),677 elapsedSeconds: Schema.optional(Schema.Number),
678 /** Owning task/agent when the tool ran inside a subagent. */
679 taskId: Schema.optional(RuntimeTaskId),
680 parentToolUseId: Schema.optional(TrimmedNonEmptyStringSchema),
516});681});
517export type ToolProgressPayload = typeof ToolProgressPayload.Type;682export type ToolProgressPayload = typeof ToolProgressPayload.Type;
518683
...@@ -835,6 +1000,13 @@ const ProviderRuntimeTaskProgressEvent = Schema.Struct({...@@ -835,6 +1000,13 @@ const ProviderRuntimeTaskProgressEvent = Schema.Struct({
835});1000});
836export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type;1001export type ProviderRuntimeTaskProgressEvent = typeof ProviderRuntimeTaskProgressEvent.Type;
8371002
1003const ProviderRuntimeTaskUpdatedEvent = Schema.Struct({
1004 ...ProviderRuntimeEventBase.fields,
1005 type: TaskUpdatedType,
1006 payload: TaskUpdatedPayload,
1007});
1008export type ProviderRuntimeTaskUpdatedEvent = typeof ProviderRuntimeTaskUpdatedEvent.Type;
1009
838const ProviderRuntimeTaskCompletedEvent = Schema.Struct({1010const ProviderRuntimeTaskCompletedEvent = Schema.Struct({
839 ...ProviderRuntimeEventBase.fields,1011 ...ProviderRuntimeEventBase.fields,
840 type: TaskCompletedType,1012 type: TaskCompletedType,
...@@ -995,6 +1167,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([...@@ -995,6 +1167,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([
995 ProviderRuntimeUserInputResolvedEvent,1167 ProviderRuntimeUserInputResolvedEvent,
996 ProviderRuntimeTaskStartedEvent,1168 ProviderRuntimeTaskStartedEvent,
997 ProviderRuntimeTaskProgressEvent,1169 ProviderRuntimeTaskProgressEvent,
1170 ProviderRuntimeTaskUpdatedEvent,
998 ProviderRuntimeTaskCompletedEvent,1171 ProviderRuntimeTaskCompletedEvent,
999 ProviderRuntimeHookStartedEvent,1172 ProviderRuntimeHookStartedEvent,
1000 ProviderRuntimeHookProgressEvent,1173 ProviderRuntimeHookProgressEvent,
packages/contracts/src/rpc.ts+11
...@@ -64,6 +64,7 @@ import {...@@ -64,6 +64,7 @@ import {
64 OrchestrationGetTurnDiffError,64 OrchestrationGetTurnDiffError,
65 OrchestrationGetTurnDiffInput,65 OrchestrationGetTurnDiffInput,
66 OrchestrationRpcSchemas,66 OrchestrationRpcSchemas,
67 OrchestrationGetWorkflowScriptError,
67} from "./orchestration.ts";68} from "./orchestration.ts";
68import { ProviderInstanceId } from "./providerInstance.ts";69import { ProviderInstanceId } from "./providerInstance.ts";
69import {70import {
...@@ -696,6 +697,15 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(...@@ -696,6 +697,15 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(
696 },697 },
697);698);
698699
700export const WsOrchestrationGetWorkflowScriptRpc = Rpc.make(
701 ORCHESTRATION_WS_METHODS.getWorkflowScript,
702 {
703 payload: OrchestrationRpcSchemas.getWorkflowScript.input,
704 success: OrchestrationRpcSchemas.getWorkflowScript.output,
705 error: Schema.Union([OrchestrationGetWorkflowScriptError, EnvironmentAuthorizationError]),
706 },
707);
708
699export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {709export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
700 payload: OrchestrationGetTurnDiffInput,710 payload: OrchestrationGetTurnDiffInput,
701 success: OrchestrationRpcSchemas.getTurnDiff.output,711 success: OrchestrationRpcSchemas.getTurnDiff.output,
...@@ -867,6 +877,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -867,6 +877,7 @@ export const WsRpcGroup = RpcGroup.make(
867 WsSubscribeBackgroundPolicyRpc,877 WsSubscribeBackgroundPolicyRpc,
868 WsSubscribeResourceTelemetryRpc,878 WsSubscribeResourceTelemetryRpc,
869 WsOrchestrationDispatchCommandRpc,879 WsOrchestrationDispatchCommandRpc,
880 WsOrchestrationGetWorkflowScriptRpc,
870 WsOrchestrationGetTurnDiffRpc,881 WsOrchestrationGetTurnDiffRpc,
871 WsOrchestrationGetFullThreadDiffRpc,882 WsOrchestrationGetFullThreadDiffRpc,
872 WsOrchestrationSearchThreadsRpc,883 WsOrchestrationSearchThreadsRpc,