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", () => {
532532 });
533533 });
534534});
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 {
8080interface DerivedWorkLogEntry extends WorkLogEntry {
8181 activityKind: OrchestrationThreadActivity["kind"];
8282 collapseKey?: string;
83 /** Grouping key for subagent lifecycle rows (one row per agent). */
84 taskId?: string;
8385}
8486
8587type RawThreadFeedEntry =
......@@ -235,6 +237,63 @@ function resolvePendingUserInputAnswer(
235237 return normalizeDraftAnswer(draft?.selectedOptionLabel);
236238}
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
238297function deriveWorkLogEntries(
239298 activities: ReadonlyArray<OrchestrationThreadActivity>,
240299): DerivedWorkLogEntry[] {
......@@ -243,9 +302,13 @@ function deriveWorkLogEntries(
243302 for (const activity of ordered) {
244303 if (activity.kind === "tool.started") continue;
245304 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;
246308 if (activity.kind === "context-window.updated") continue;
247309 if (activity.summary === "Checkpoint captured") continue;
248310 if (isPlanBoundaryToolActivity(activity)) continue;
311 if (isAgentInternalActivity(activity)) continue;
249312 entries.push(toDerivedWorkLogEntry(activity));
250313 }
251314 return collapseDerivedWorkLogEntries(entries);
......@@ -271,7 +334,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
271334 const commandPreview = extractToolCommand(payload);
272335 const changedFiles = extractChangedFiles(payload);
273336 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";
275344 const taskSummary =
276345 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
277346 ? payload.summary
......@@ -284,10 +353,15 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
284353 ? payload.detail
285354 : null;
286355 const taskLabel = taskSummary || taskDetailAsLabel;
356 const taskId =
357 isTaskActivity && typeof payload?.taskId === "string" && payload.taskId.length > 0
358 ? payload.taskId
359 : undefined;
287360 const entry: DerivedWorkLogEntry = {
288361 id: activity.id,
289362 createdAt: activity.createdAt,
290363 turnId: activity.turnId,
364 ...(taskId ? { taskId } : {}),
291365 label: taskLabel || activity.summary,
292366 tone:
293367 activity.kind === "task.progress"
......@@ -352,7 +426,25 @@ function collapseDerivedWorkLogEntries(
352426 entries: ReadonlyArray<DerivedWorkLogEntry>,
353427): DerivedWorkLogEntry[] {
354428 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>();
355432 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 }
356448 const previous = collapsed.at(-1);
357449 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
358450 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
5050import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
5151import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
5252import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
53import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts";
5354import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
5455import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
5556import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
......@@ -305,7 +306,7 @@ export const makeOrchestrationIntegrationHarness = (
305306 checkpointStoreLayer,
306307 providerLayer,
307308 RuntimeReceiptBusTest,
308 );
309 ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer));
309310 const serverSettingsLayer = ServerSettingsService.layerTest();
310311 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
311312 Layer.provideMerge(runtimeServicesLayer),
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -22,6 +22,7 @@ type WsRpcMethod = RpcGroup.Rpcs<typeof WsRpcGroup>["_tag"];
2222 */
2323export const RPC_REQUIRED_SCOPES = {
2424 [ORCHESTRATION_WS_METHODS.dispatchCommand]: AuthOrchestrationOperateScope,
25 [ORCHESTRATION_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
2526 [ORCHESTRATION_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
2627 [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
2728 [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";
3939import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4040import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4141import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
42import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
4243import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts";
4344import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
4445import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
......@@ -294,6 +295,7 @@ describe("CheckpointReactor", () => {
294295 );
295296 const orchestrationLayer = OrchestrationEngineLive.pipe(
296297 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
298 Layer.provide(ThreadBackgroundLiveness.layer),
297299 Layer.provide(OrchestrationProjectionPipelineLive),
298300 Layer.provide(OrchestrationEventStoreLive),
299301 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -301,6 +303,7 @@ describe("CheckpointReactor", () => {
301303 Layer.provide(SqlitePersistenceMemory),
302304 );
303305 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
306 Layer.provide(ThreadBackgroundLiveness.layer),
304307 Layer.provide(RepositoryIdentityResolver.layer),
305308 Layer.provide(SqlitePersistenceMemory),
306309 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5
......@@ -31,6 +31,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
3131import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3232import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3333import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
3435import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3536import {
3637 OrchestrationProjectionPipeline,
......@@ -55,6 +56,7 @@ async function createOrchestrationSystem() {
5556 ),
5657 OrchestrationProjectionSnapshotQueryLive,
5758 ).pipe(
59 Layer.provide(ThreadBackgroundLiveness.layer),
5860 Layer.provide(OrchestrationEventStoreLive),
5961 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
6062 Layer.provide(RepositoryIdentityResolver.layer),
......@@ -817,6 +819,7 @@ describe("OrchestrationEngine", () => {
817819 const runtime = ManagedRuntime.make(
818820 OrchestrationEngineLive.pipe(
819821 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
822 Layer.provide(ThreadBackgroundLiveness.layer),
820823 Layer.provide(OrchestrationProjectionPipelineLive),
821824 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
822825 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -922,6 +925,7 @@ describe("OrchestrationEngine", () => {
922925 const runtime = ManagedRuntime.make(
923926 OrchestrationEngineLive.pipe(
924927 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
928 Layer.provide(ThreadBackgroundLiveness.layer),
925929 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
926930 Layer.provide(OrchestrationEventStoreLive),
927931 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -1065,6 +1069,7 @@ describe("OrchestrationEngine", () => {
10651069 const runtime = ManagedRuntime.make(
10661070 OrchestrationEngineLive.pipe(
10671071 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
1072 Layer.provide(ThreadBackgroundLiveness.layer),
10681073 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
10691074 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
10701075 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2
......@@ -31,6 +31,7 @@ import {
3131 OrchestrationProjectionPipelineLive,
3232} from "./ProjectionPipeline.ts";
3333import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
34import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
3435import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3536import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
3637import { ServerConfig } from "../../config.ts";
......@@ -2664,6 +2665,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
26642665const engineLayer = it.layer(
26652666 OrchestrationEngineLive.pipe(
26662667 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
2668 Layer.provide(ThreadBackgroundLiveness.layer),
26672669 Layer.provide(OrchestrationProjectionPipelineLive),
26682670 Layer.provide(OrchestrationEventStoreLive),
26692671 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+4
......@@ -17,6 +17,7 @@ import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
1717import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
1818import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
1919import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
20import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
2021import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
2122
2223const asProjectId = (value: string): ProjectId => ProjectId.make(value);
......@@ -27,6 +28,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val
2728
2829const projectionSnapshotLayer = it.layer(
2930 OrchestrationProjectionSnapshotQueryLive.pipe(
31 Layer.provide(ThreadBackgroundLiveness.layer),
3032 Layer.provideMerge(RepositoryIdentityResolver.layer),
3133 Layer.provideMerge(SqlitePersistenceMemory),
3234 Layer.provideMerge(NodeServices.layer),
......@@ -443,6 +445,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
443445 hasPendingApprovals: true,
444446 hasPendingUserInput: false,
445447 hasActionableProposedPlan: false,
448 backgroundLiveness: null,
446449 },
447450 ]);
448451
......@@ -1825,6 +1828,7 @@ it.effect(
18251828 () => {
18261829 const resolveCalls: string[] = [];
18271830 const layer = OrchestrationProjectionSnapshotQueryLive.pipe(
1831 Layer.provide(ThreadBackgroundLiveness.layer),
18281832 Layer.provideMerge(
18291833 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
18301834 resolve: (cwd: string) =>
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+11
......@@ -43,6 +43,7 @@ import {
4343 type ProjectionRepositoryError,
4444} from "../../persistence/Errors.ts";
4545import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts";
46import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
4647import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
4748import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
4849import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
......@@ -308,6 +309,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
308309}
309310
310311const makeProjectionSnapshotQuery = Effect.gen(function* () {
312 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
311313 const sql = yield* SqlClient.SqlClient;
312314 const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
313315 const repositoryIdentityResolutionConcurrency = 4;
......@@ -1678,6 +1680,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16781680 hasPendingApprovals: row.pendingApprovalCount > 0,
16791681 hasPendingUserInput: row.pendingUserInputCount > 0,
16801682 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1683 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
1684 row.threadId,
1685 ),
16811686 } satisfies OrchestrationThreadShell)
16821687 : Result.failVoid,
16831688 ),
......@@ -1818,6 +1823,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
18181823 hasPendingApprovals: row.pendingApprovalCount > 0,
18191824 hasPendingUserInput: row.pendingUserInputCount > 0,
18201825 hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
1826 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
1827 row.threadId,
1828 ),
18211829 }),
18221830 ),
18231831 updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
......@@ -2090,6 +2098,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20902098 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
20912099 hasPendingUserInput: threadRow.value.pendingUserInputCount > 0,
20922100 hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0,
2101 backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness(
2102 threadRow.value.threadId,
2103 ),
20932104 } satisfies OrchestrationThreadShell);
20942105 });
20952106
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3
......@@ -48,6 +48,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
4848import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4949import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
5050import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
51import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
5152import {
5253 providerErrorLabel,
5354 providerErrorLabelFromInstanceHint,
......@@ -345,6 +346,7 @@ describe("ProviderCommandReactor", () => {
345346
346347 const orchestrationLayer = OrchestrationEngineLive.pipe(
347348 Layer.provide(OrchestrationProjectionSnapshotQueryLive),
349 Layer.provide(ThreadBackgroundLiveness.layer),
348350 Layer.provide(OrchestrationProjectionPipelineLive),
349351 Layer.provide(OrchestrationEventStoreLive),
350352 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
......@@ -352,6 +354,7 @@ describe("ProviderCommandReactor", () => {
352354 Layer.provide(SqlitePersistenceMemory),
353355 );
354356 const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe(
357 Layer.provide(ThreadBackgroundLiveness.layer),
355358 Layer.provide(RepositoryIdentityResolver.layer),
356359 Layer.provide(SqlitePersistenceMemory),
357360 );
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+10-3
......@@ -44,6 +44,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes
4444import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4545import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
4646import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
47import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
4748import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts";
4849import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
4950import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
......@@ -237,6 +238,9 @@ describe("ProviderRuntimeIngestion", () => {
237238 const layer = ProviderRuntimeIngestionLive.pipe(
238239 Layer.provideMerge(orchestrationLayer),
239240 Layer.provideMerge(projectionSnapshotLayer),
241 // Single shared liveness instance across ingestion (writer), the
242 // engine, and the snapshot query (reader).
243 Layer.provideMerge(ThreadBackgroundLiveness.layer),
240244 Layer.provideMerge(SqlitePersistenceMemory),
241245 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
242246 Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
......@@ -3143,7 +3147,8 @@ describe("ProviderRuntimeIngestion", () => {
31433147 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-started",
31443148 );
31453149 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",
31473152 );
31483153 const completed = thread.activities.find(
31493154 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-task-completed",
......@@ -3227,7 +3232,8 @@ describe("ProviderRuntimeIngestion", () => {
32273232 );
32283233
32293234 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",
32313237 );
32323238 const completed = thread.activities.find(
32333239 (activity: ProviderRuntimeTestActivity) => activity.id === "evt-named-task-completed",
......@@ -3318,7 +3324,8 @@ describe("ProviderRuntimeIngestion", () => {
33183324
33193325 await waitForThread(harness.readModel, (entry) =>
33203326 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",
33223329 ),
33233330 );
33243331
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+176-1
......@@ -7,6 +7,8 @@ import {
77 type OrchestrationMessage,
88 type OrchestrationProposedPlanId,
99 CheckpointRef,
10 classifyTaskAgentKind,
11 EventId,
1012 isToolLifecycleItemType,
1113 ThreadId,
1214 type ThreadTokenUsageSnapshot,
......@@ -32,6 +34,7 @@ import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionT
3234import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
3335import { isGitRepository } from "../../git/Utils.ts";
3436import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
37import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts";
3538import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
3639import {
3740 ProviderRuntimeIngestionService,
......@@ -307,6 +310,52 @@ function requestKindFromCanonicalRequestType(
307310 }
308311}
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
310359export function runtimeEventToActivities(
311360 event: ProviderRuntimeEvent,
312361 taskTitle?: string,
......@@ -505,6 +554,7 @@ export function runtimeEventToActivities(
505554 ...(event.payload.description
506555 ? { detail: truncateDetail(event.payload.description) }
507556 : {}),
557 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
508558 },
509559 turnId: toTurnId(event.turnId) ?? null,
510560 ...maybeSequence,
......@@ -515,7 +565,16 @@ export function runtimeEventToActivities(
515565 case "task.progress": {
516566 return [
517567 {
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}`),
519578 createdAt: event.createdAt,
520579 tone: "info",
521580 kind: "task.progress",
......@@ -532,6 +591,71 @@ export function runtimeEventToActivities(
532591 ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}),
533592 ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}),
534593 ...(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 : {}),
535659 },
536660 turnId: toTurnId(event.turnId) ?? null,
537661 ...maybeSequence,
......@@ -565,6 +689,7 @@ export function runtimeEventToActivities(
565689 }
566690 : {}),
567691 ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
692 ...taskLinkageActivityFields(event.payload as Record<string, unknown>),
568693 },
569694 turnId: toTurnId(event.turnId) ?? null,
570695 ...maybeSequence,
......@@ -630,6 +755,10 @@ export function runtimeEventToActivities(
630755 ...(event.payload.status ? { status: event.payload.status } : {}),
631756 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
632757 ...(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 : {}),
633762 },
634763 turnId: toTurnId(event.turnId) ?? null,
635764 ...maybeSequence,
......@@ -652,6 +781,10 @@ export function runtimeEventToActivities(
652781 itemType: event.payload.itemType,
653782 ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
654783 ...(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 : {}),
655788 },
656789 turnId: toTurnId(event.turnId) ?? null,
657790 ...maybeSequence,
......@@ -673,6 +806,10 @@ export function runtimeEventToActivities(
673806 payload: {
674807 itemType: event.payload.itemType,
675808 ...(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 : {}),
676813 },
677814 turnId: toTurnId(event.turnId) ?? null,
678815 ...maybeSequence,
......@@ -688,6 +825,7 @@ export function runtimeEventToActivities(
688825}
689826
690827const make = Effect.gen(function* () {
828 const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService;
691829 const crypto = yield* Crypto.Crypto;
692830 const orchestrationEngine = yield* OrchestrationEngineService;
693831 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
......@@ -1756,6 +1894,43 @@ const make = Effect.gen(function* () {
17561894 yield* rememberTaskDescription(thread.id, event.payload.taskId, description);
17571895 }
17581896 }
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
17591934 let taskTitle: string | undefined;
17601935 if (event.type === "task.completed") {
17611936 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
55import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts";
66import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts";
77import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts";
8import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
89
910export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll(
1011 OrchestrationEventStoreLive,
......@@ -19,7 +20,10 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll(
1920 OrchestrationProjectionSnapshotQueryLive,
2021 OrchestrationEventInfrastructureLayerLive,
2122 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
2428export const OrchestrationLayerLive = Layer.mergeAll(
2529 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> {
5555 private failure: unknown | undefined;
5656
5757 public readonly interruptCalls: Array<void> = [];
58 public readonly stopTaskCalls: Array<string> = [];
5859 public readonly setModelCalls: Array<string | undefined> = [];
5960 public readonly setPermissionModeCalls: Array<string> = [];
6061 public readonly setMaxThinkingTokensCalls: Array<number | null> = [];
......@@ -98,6 +99,10 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
9899 this.interruptCalls.push(undefined);
99100 };
100101
102 readonly stopTask = async (taskId: string): Promise<void> => {
103 this.stopTaskCalls.push(taskId);
104 };
105
101106 readonly setModel = async (model?: string): Promise<void> => {
102107 this.setModelCalls.push(model);
103108 };
......@@ -1445,6 +1450,243 @@ describe("ClaudeAdapterLive", () => {
14451450 );
14461451 });
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
14481690 it.effect("closes the session when the Claude stream aborts after a turn starts", () => {
14491691 const harness = makeHarness();
14501692 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+586-12
......@@ -42,6 +42,10 @@ import {
4242 RuntimeItemId,
4343 RuntimeRequestId,
4444 RuntimeTaskId,
45 type RuntimeTaskStatus,
46 type RuntimeTaskUsage,
47 type TaskAgentLinkage,
48 type TaskRunHandles,
4549 ThreadId,
4650 TurnId,
4751 type UserInputQuestion,
......@@ -169,6 +173,9 @@ interface ToolInFlight {
169173 readonly input: Record<string, unknown>;
170174 readonly partialInputJson: string;
171175 readonly lastEmittedInputFingerprint?: string;
176 /** Owning agent when this tool ran inside a subagent (see attribution note). */
177 readonly agentId?: string;
178 readonly parentToolUseId?: string;
172179}
173180
174181interface ClaudeTaskState {
......@@ -178,6 +185,28 @@ interface ClaudeTaskState {
178185 readonly blockedBy: Set<string>;
179186}
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
181210interface ClaudeSessionContext {
182211 session: ProviderSession;
183212 readonly promptQueue: Queue.Queue<PromptQueueItem>;
......@@ -186,6 +215,9 @@ interface ClaudeSessionContext {
186215 readonly startedAt: string;
187216 readonly basePermissionMode: PermissionMode | undefined;
188217 currentApiModelId: string | undefined;
218 /** Effective effort for the session's turns; subagents without an explicit
219 * effort override inherit this. */
220 currentEffort: string | undefined;
189221 resumeSessionId: string | undefined;
190222 readonly pendingApprovals: Map<ApprovalRequestId, PendingApproval>;
191223 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
......@@ -195,6 +227,17 @@ interface ClaudeSessionContext {
195227 }>;
196228 readonly inFlightTools: Map<number, ToolInFlight>;
197229 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>;
198241 turnState: ClaudeTurnState | undefined;
199242 lastKnownContextWindow: number | undefined;
200243 lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;
......@@ -206,6 +249,8 @@ interface ClaudeSessionContext {
206249
207250interface ClaudeQueryRuntime extends AsyncIterable<SDKMessage> {
208251 readonly interrupt: () => Promise<void>;
252 /** SDK Query.stopTask — present on real queries; optional for test doubles. */
253 readonly stopTask?: (taskId: string) => Promise<void>;
209254 readonly setModel: (model?: string) => Promise<void>;
210255 readonly setPermissionMode: (mode: PermissionMode) => Promise<void>;
211256 readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise<void>;
......@@ -826,6 +871,225 @@ function planStepsFromClaudeTasks(tasks: Map<string, ClaudeTaskState>): PlanStep
826871 });
827872}
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
8291093function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
8301094 const commandValue = input.command ?? input.cmd;
8311095 const command = typeof commandValue === "string" ? commandValue : undefined;
......@@ -833,17 +1097,17 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>):
8331097 return `${toolName}: ${command.trim().slice(0, 400)}`;
8341098 }
8351099
836 // For agent/subagent tools, prefer human-readable description or prompt over raw JSON
1100 // 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.
8371103 const itemType = classifyToolItemType(toolName);
8381104 if (itemType === "collab_agent_tool_call") {
8391105 const description =
8401106 typeof input.description === "string" ? input.description.trim() : undefined;
8411107 const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined;
842 const subagentType =
843 typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined;
8441108 const label = description || (prompt ? prompt.slice(0, 200) : undefined);
8451109 if (label) {
846 return subagentType ? `${subagentType}: ${label}` : label;
1110 return label;
8471111 }
8481112 }
8491113
......@@ -2078,6 +2342,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
20782342
20792343 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
20812371 if (event.type === "message_delta") {
20822372 if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {
20832373 return;
......@@ -2205,6 +2495,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
22052495 status: "inProgress",
22062496 title: nextTool.title,
22072497 ...(nextTool.detail ? { detail: nextTool.detail } : {}),
2498 ...(nextTool.agentId ? { agentId: nextTool.agentId } : {}),
2499 ...(nextTool.parentToolUseId ? { parentToolUseId: nextTool.parentToolUseId } : {}),
22082500 data: {
22092501 toolName: nextTool.toolName,
22102502 input: nextTool.input,
......@@ -2274,6 +2566,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
22742566 const inputFingerprint =
22752567 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
22772577 const tool: ToolInFlight = {
22782578 itemId,
22792579 itemType,
......@@ -2283,6 +2583,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
22832583 input: toolInput,
22842584 partialInputJson: "",
22852585 ...(inputFingerprint ? { lastEmittedInputFingerprint: inputFingerprint } : {}),
2586 ...(owningAgentId ? { agentId: owningAgentId } : {}),
2587 ...(parentToolUseId ? { parentToolUseId } : {}),
22862588 };
22872589 context.inFlightTools.set(index, tool);
22882590
......@@ -2300,6 +2602,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
23002602 status: "inProgress",
23012603 title: tool.title,
23022604 ...(tool.detail ? { detail: tool.detail } : {}),
2605 ...(tool.agentId ? { agentId: tool.agentId } : {}),
2606 ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
23032607 data: {
23042608 toolName: tool.toolName,
23052609 input: toolInput,
......@@ -2378,6 +2682,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
23782682 status: toolResult.isError ? "failed" : "inProgress",
23792683 title: tool.title,
23802684 ...(tool.detail ? { detail: tool.detail } : {}),
2685 ...(tool.agentId ? { agentId: tool.agentId } : {}),
2686 ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
23812687 data: toolData,
23822688 },
23832689 providerRefs: nativeProviderRefs(context, {
......@@ -2430,6 +2736,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
24302736 status: itemStatus,
24312737 title: tool.title,
24322738 ...(tool.detail ? { detail: tool.detail } : {}),
2739 ...(tool.agentId ? { agentId: tool.agentId } : {}),
2740 ...(tool.parentToolUseId ? { parentToolUseId: tool.parentToolUseId } : {}),
24332741 data: toolData,
24342742 },
24352743 providerRefs: nativeProviderRefs(context, {
......@@ -2442,6 +2750,43 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
24422750 },
24432751 });
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
24452790 if (
24462791 !toolResult.isError &&
24472792 applyClaudeTaskToolResult(context.claudeTasks, tool, toolUseResult)
......@@ -2465,6 +2810,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
24652810 return;
24662811 }
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
24682833 // Auto-start a synthetic turn for assistant messages that arrive without
24692834 // an active turn (e.g., background agent/subagent responses between user prompts).
24702835 if (!context.turnState) {
......@@ -2563,6 +2928,82 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
25632928 yield* completeTurn(context, status, errorMessage, message);
25642929 });
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
25663007 const handleSystemMessage = Effect.fn("handleSystemMessage")(function* (
25673008 context: ClaudeSessionContext,
25683009 message: SDKMessage,
......@@ -2678,7 +3119,46 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
26783119 },
26793120 });
26803121 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);
26823162 yield* offerRuntimeEvent({
26833163 ...base,
26843164 type: "task.started",
......@@ -2686,10 +3166,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
26863166 taskId: RuntimeTaskId.make(message.task_id),
26873167 description: message.description,
26883168 ...(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 } : {}),
26893176 },
26903177 });
26913178 return;
2692 case "task_progress":
3179 }
3180 case "task_progress": {
26933181 yield* emitThreadTokenUsage(
26943182 context,
26953183 normalizeClaudeTaskProgressTokenUsage(message.usage, context),
......@@ -2698,6 +3186,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
26983186 rawPayload: message,
26993187 },
27003188 );
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;
27013198 yield* offerRuntimeEvent({
27023199 ...base,
27033200 type: "task.progress",
......@@ -2706,16 +3203,48 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27063203 description: message.description,
27073204 ...(message.summary ? { summary: message.summary } : {}),
27083205 ...(message.usage ? { usage: message.usage } : {}),
3206 ...(typedUsage ? { typedUsage } : {}),
27093207 ...(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 } : {}),
27103211 },
27113212 });
3213 yield* emitWorkflowMemberProgress(context, base, message);
27123214 return;
2713 // Task state patch (status/backgrounded/end_time). No runtime mapping
2714 // yet — the terminal task_notification reports the outcome — but it
2715 // must not surface as an unknown-subtype warning row.
2716 case "task_updated":
3215 }
3216 case "task_updated": {
3217 // Status patch (killed/paused/backgrounded/end_time/error) — main
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 });
27173244 return;
2718 case "task_notification":
3245 }
3246 case "task_notification": {
3247 context.liveTaskIds.delete(message.task_id);
27193248 yield* emitThreadTokenUsage(
27203249 context,
27213250 normalizeClaudeTaskProgressTokenUsage(message.usage, context),
......@@ -2724,6 +3253,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27243253 rawPayload: message,
27253254 },
27263255 );
3256 const typedUsage = normalizeTaskUsage(message.usage);
27273257 yield* offerRuntimeEvent({
27283258 ...base,
27293259 type: "task.completed",
......@@ -2732,9 +3262,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
27323262 status: message.status,
27333263 ...(message.summary ? { summary: message.summary } : {}),
27343264 ...(message.usage ? { usage: message.usage } : {}),
3265 ...(typedUsage ? { typedUsage } : {}),
3266 ...(message.output_file ? { outputFile: message.output_file } : {}),
3267 ...taskLinkageFor(context.taskAgents, message.task_id),
27353268 },
27363269 });
27373270 return;
3271 }
27383272 case "files_persisted":
27393273 yield* offerRuntimeEvent({
27403274 ...base,
......@@ -2870,7 +3404,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
28703404 toolUseId: message.tool_use_id,
28713405 toolName: message.tool_name,
28723406 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 : {}),
28743411 },
28753412 });
28763413 return;
......@@ -3192,6 +3729,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
31923729 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
31933730 const inFlightTools = new Map<number, ToolInFlight>();
31943731 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
31963736 const contextRef = yield* Ref.make<ClaudeSessionContext | undefined>(undefined);
31973737
......@@ -3628,12 +4168,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
36284168 startedAt,
36294169 basePermissionMode: permissionMode,
36304170 currentApiModelId: apiModelId,
4171 currentEffort: effectiveEffort ?? undefined,
36314172 resumeSessionId: sessionId,
36324173 pendingApprovals,
36334174 pendingUserInputs,
36344175 turns: [],
36354176 inFlightTools,
36364177 claudeTasks,
4178 taskAgents,
4179 workflowMemberFingerprints,
4180 liveTaskIds,
36374181 turnState: undefined,
36384182 lastKnownContextWindow: initialContextWindow,
36394183 lastKnownTokenUsage: undefined,
......@@ -3750,6 +4294,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
37504294 ...context.session,
37514295 model: modelSelection.model,
37524296 };
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;
37534304 }
37544305
37554306 // Apply interaction mode by switching the SDK's permission mode.
......@@ -3825,6 +4376,29 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
38254376 const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(
38264377 function* (threadId, _turnId) {
38274378 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 }
38284402 yield* Effect.tryPromise({
38294403 try: () => context.query.interrupt(),
38304404 catch: (cause) => toRequestError(threadId, "turn/interrupt", cause),
apps/server/src/provider/Layers/CodexAdapter.ts+268
......@@ -20,6 +20,8 @@ import {
2020 type ProviderUserInputAnswers,
2121 RuntimeItemId,
2222 RuntimeRequestId,
23 RuntimeTaskId,
24 type RuntimeTaskUsage,
2325 ProviderApprovalDecision,
2426 ThreadId,
2527 ProviderSendTurnInput,
......@@ -494,10 +496,276 @@ function mapItemLifecycle(
494496 };
495497}
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
497762function mapToRuntimeEvents(
498763 event: ProviderEvent,
499764 canonicalThreadId: ThreadId,
500765): ReadonlyArray<ProviderRuntimeEvent> {
766 if (event.kind === "notification" && event.method.startsWith("collabAgent/")) {
767 return mapCollabAgentEvent(event, canonicalThreadId);
768 }
501769 if (event.kind === "error") {
502770 if (!event.message) {
503771 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): {
600600 }
601601}
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
603668function rememberCollabReceiverTurns(
604669 collabReceiverTurns: Map<string, TurnId>,
605670 notification: CodexServerNotification,
......@@ -641,6 +706,72 @@ function shouldSuppressChildConversationNotification(
641706 );
642707}
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
644775function toCodexUserInputAnswer(
645776 questionId: string,
646777 value: ProviderUserInputAnswers[string],
......@@ -723,6 +854,9 @@ export const makeCodexSessionRuntime = (
723854 const approvalCorrelationsRef = yield* Ref.make(new Map<string, ApprovalCorrelation>());
724855 const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>());
725856 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>());
726860 const closedRef = yield* Ref.make(false);
727861
728862 // `~` is not shell-expanded when env vars are set via
......@@ -839,6 +973,280 @@ export const makeCodexSessionRuntime = (
839973 ),
840974 );
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
8421250 const handleRawNotification = (notification: CodexServerNotification) =>
8431251 Effect.gen(function* () {
8441252 const payload = notification.params;
......@@ -852,7 +1260,68 @@ export const makeCodexSessionRuntime = (
8521260 })();
8531261
8541262 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 }
8561325 yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns);
8571326 return;
8581327 }
......@@ -1331,6 +1800,26 @@ export const makeCodexSessionRuntime = (
13311800 Effect.gen(function* () {
13321801 const providerThreadId = yield* readProviderThreadId;
13331802 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);
13341823 const effectiveTurnId = turnId ?? session.activeTurnId;
13351824 if (!effectiveTurnId) {
13361825 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";
9191import * as PortScanner from "./preview/PortScanner.ts";
9292import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts";
9393import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts";
94import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts";
9495import * as WorkspacePaths from "./workspace/WorkspacePaths.ts";
9596import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
9697import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
......@@ -1084,6 +1085,12 @@ const makeWsRpcLayer = (
10841085 ),
10851086 { "rpc.aggregate": "orchestration" },
10861087 ),
1088 [ORCHESTRATION_WS_METHODS.getWorkflowScript]: (input) =>
1089 observeRpcEffect(
1090 ORCHESTRATION_WS_METHODS.getWorkflowScript,
1091 readWorkflowScript({ scriptPath: input.scriptPath }),
1092 { "rpc.aggregate": "orchestration" },
1093 ),
10871094 [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) =>
10881095 observeRpcEffect(
10891096 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 {
140140 usePreviewMiniPlayerStore,
141141} from "../previewMiniPlayerStore";
142142import { RightPanelTabs } from "./RightPanelTabs";
143import { AgentsPanel } from "./AgentsPanel";
144import {
145 deriveAgentPanelModel,
146 foldSubagentActivities,
147} from "@t3tools/client-runtime/state/subagentRuntime";
143148import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
144149import { BranchToolbar } from "./BranchToolbar";
145150import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
......@@ -2052,6 +2057,18 @@ function ChatViewContent(props: ChatViewProps) {
20522057 const phase = derivePhase(activeThread?.session ?? null);
20532058 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
20542059 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 );
20552072 const pendingApprovals = useMemo(
20562073 () => derivePendingApprovals(threadActivities),
20572074 [threadActivities],
......@@ -3134,6 +3151,10 @@ function ChatViewContent(props: ChatViewProps) {
31343151 if (!activeThreadRef || !activeProject) return;
31353152 useRightPanelStore.getState().open(activeThreadRef, "files");
31363153 }, [activeProject, activeThreadRef]);
3154 const addAgentsSurface = useCallback(() => {
3155 if (!activeThreadRef) return;
3156 useRightPanelStore.getState().open(activeThreadRef, "agents");
3157 }, [activeThreadRef]);
31373158 const openFileSurface = useCallback(
31383159 (relativePath: string) => {
31393160 if (!activeThreadRef || !activeProject) return;
......@@ -4167,6 +4188,85 @@ function ChatViewContent(props: ChatViewProps) {
41674188 switchGitRef,
41684189 updateThreadMetadata,
41694190 ]);
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 ]);
41704270 // The stack renders items[0] front-most and tucks the rest behind hover, so
41714271 // ordering is priority: system banners, then the branch-mismatch notice,
41724272 // and the informational parked-thread banner last — it must never cover another.
......@@ -4219,12 +4319,15 @@ function ChatViewContent(props: ChatViewProps) {
42194319 void handleSwitchCheckoutToThread();
42204320 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
42214321 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
4322 const backgroundLivenessItems =
4323 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
42224324 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
42234325 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
4224 return [...systemComposerBannerItems, ...parkedThreadItems];
4326 return [...systemComposerBannerItems, ...backgroundLivenessItems, ...parkedThreadItems];
42254327 }
42264328 return [
42274329 ...systemComposerBannerItems,
4330 ...backgroundLivenessItems,
42284331 {
42294332 id: `branch-mismatch:${activeBranchMismatchKey}`,
42304333 variant: "info",
......@@ -4268,6 +4371,7 @@ function ChatViewContent(props: ChatViewProps) {
42684371 ];
42694372 }, [
42704373 activeBranchMismatchKey,
4374 backgroundLivenessBannerItem,
42714375 handleRestoreThreadBranch,
42724376 isRestoringThreadBranch,
42734377 localCheckoutBranchMismatch,
......@@ -5764,6 +5868,12 @@ function ChatViewContent(props: ChatViewProps) {
57645868 timestampFormat={timestampFormat}
57655869 mode="embedded"
57665870 />
5871 ) : activeRightPanelSurface?.kind === "agents" ? (
5872 <AgentsPanel
5873 model={agentPanelModel}
5874 environmentId={activeThreadRef?.environmentId ?? null}
5875 threadId={activeThreadRef?.threadId ?? null}
5876 />
57675877 ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") &&
57685878 activeProject &&
57695879 activeWorkspaceRoot ? (
......@@ -5859,6 +5969,8 @@ function ChatViewContent(props: ChatViewProps) {
58595969 <div className="relative flex min-h-0 flex-1 flex-col">
58605970 {/* Messages — LegendList handles virtualization and scrolling internally */}
58615971 <MessagesTimeline
5972 agentPanelModel={agentPanelModel}
5973 onOpenAgents={addAgentsSurface}
58625974 key={activeThread.id}
58635975 isWorking={isWorking}
58645976 activeTurnInProgress={isWorking || !latestTurnSettled}
......@@ -6195,6 +6307,7 @@ function ChatViewContent(props: ChatViewProps) {
61956307 onAddTerminal={addTerminalSurface}
61966308 onAddDiff={addDiffSurface}
61976309 onAddFiles={addFilesSurface}
6310 onAddAgents={addAgentsSurface}
61986311 browserAvailable={isPreviewSupportedInRuntime()}
61996312 diffAvailable={isServerThread && isGitRepo}
62006313 filesAvailable={activeProject !== null}
......@@ -6222,6 +6335,7 @@ function ChatViewContent(props: ChatViewProps) {
62226335 onAddTerminal={addTerminalSurface}
62236336 onAddDiff={addDiffSurface}
62246337 onAddFiles={addFilesSurface}
6338 onAddAgents={addAgentsSurface}
62256339 browserAvailable={isPreviewSupportedInRuntime()}
62266340 diffAvailable={isServerThread && isGitRepo}
62276341 filesAvailable={activeProject !== null}
apps/web/src/components/RightPanelTabs.tsx+20-1
......@@ -1,6 +1,6 @@
11import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts";
22import { 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";
44import {
55 type MouseEvent as ReactMouseEvent,
66 type ReactElement,
......@@ -44,6 +44,7 @@ interface RightPanelTabsProps {
4444 onAddTerminal: () => void;
4545 onAddDiff: () => void;
4646 onAddFiles: () => void;
47 onAddAgents: () => void;
4748 browserAvailable: boolean;
4849 diffAvailable: boolean;
4950 filesAvailable: boolean;
......@@ -91,6 +92,7 @@ function RightPanelEmptyState(props: {
9192 onAddTerminal: () => void;
9293 onAddDiff: () => void;
9394 onAddFiles: () => void;
95 onAddAgents: () => void;
9496 browserAvailable: boolean;
9597 diffAvailable: boolean;
9698 filesAvailable: boolean;
......@@ -128,6 +130,14 @@ function RightPanelEmptyState(props: {
128130 disabledReason: SURFACE_DISABLED_REASONS.diff,
129131 onClick: props.onAddDiff,
130132 },
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 },
131141 ] as const;
132142
133143 return (
......@@ -205,6 +215,8 @@ function surfaceTitle(
205215 );
206216 case "plan":
207217 return "Plan";
218 case "agents":
219 return "Agents";
208220 case "preview": {
209221 const snapshot = surface.resourceId ? sessions[surface.resourceId] : null;
210222 if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser";
......@@ -266,6 +278,8 @@ function SurfaceIcon({
266278 return <TerminalSquare className="size-3 shrink-0" />;
267279 case "plan":
268280 return <ClipboardList className="size-3 shrink-0" />;
281 case "agents":
282 return <Bot className="size-3 shrink-0" />;
269283 }
270284}
271285
......@@ -466,6 +480,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
466480 <FileDiff />
467481 Diff
468482 </SurfaceMenuItem>
483 <SurfaceMenuItem available onClick={props.onAddAgents}>
484 <Bot />
485 Agents
486 </SurfaceMenuItem>
469487 </MenuPopup>
470488 </Menu>
471489 ) : null}
......@@ -480,6 +498,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
480498 onAddTerminal={props.onAddTerminal}
481499 onAddDiff={props.onAddDiff}
482500 onAddFiles={props.onAddFiles}
501 onAddAgents={props.onAddAgents}
483502 browserAvailable={props.browserAvailable}
484503 diffAvailable={props.diffAvailable}
485504 filesAvailable={props.filesAvailable}
apps/web/src/components/Sidebar.logic.ts+47-7
......@@ -120,6 +120,7 @@ export function buildBulkTitleRegenerationContextMenuItem(input: {
120120export interface ThreadStatusPill {
121121 label:
122122 | "Working"
123 | "Monitoring"
123124 | "Connecting"
124125 | "Completed"
125126 | "Pending Approval"
......@@ -130,12 +131,16 @@ export interface ThreadStatusPill {
130131 pulse: boolean;
131132}
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.
133137const THREAD_STATUS_PRIORITY: Record<ThreadStatusPill["label"], number> = {
134 "Pending Approval": 5,
135 "Awaiting Input": 4,
136 Working: 3,
137 Connecting: 3,
138 "Plan Ready": 2,
138 "Pending Approval": 6,
139 "Awaiting Input": 5,
140 Working: 4,
141 Connecting: 4,
142 "Plan Ready": 3,
143 Monitoring: 2,
139144 Completed: 1,
140145};
141146
......@@ -147,6 +152,7 @@ type ThreadStatusInput = Pick<
147152 | "interactionMode"
148153 | "latestTurn"
149154 | "session"
155 | "backgroundLiveness"
150156> & {
151157 lastVisitedAt?: string | undefined;
152158};
......@@ -424,11 +430,11 @@ export function resolveThreadRowClassName(input: {
424430// whether it finished, asked a question, or proposed a plan.
425431// Unread completion is tracked separately: it describes whether a ready
426432// 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
429435type SidebarV2StatusInput = Pick<
430436 SidebarThreadSummary,
431 "hasPendingApprovals" | "hasPendingUserInput" | "session"
437 "hasPendingApprovals" | "hasPendingUserInput" | "session" | "backgroundLiveness"
432438>;
433439
434440export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2Status {
......@@ -441,9 +447,19 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S
441447 if (thread.session?.status === "running" || thread.session?.status === "starting") {
442448 return "working";
443449 }
450 // A failed session outranks lingering background liveness: the user must
451 // see the failure, not a stale Working (review finding).
444452 if (thread.session?.status === "error") {
445453 return "failed";
446454 }
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 }
447463 return "ready";
448464}
449465
......@@ -616,6 +632,8 @@ export function resolveThreadStatusPill(input: {
616632 };
617633 }
618634
635 // An actionable plan prompt outranks lingering background work: it needs
636 // the user's decision, while liveness merely reports (review finding).
619637 const hasPlanReadyPrompt =
620638 !thread.hasPendingUserInput &&
621639 thread.interactionMode === "plan" &&
......@@ -630,6 +648,28 @@ export function resolveThreadStatusPill(input: {
630648 };
631649 }
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
633673 if (hasUnseenCompletion(thread)) {
634674 return {
635675 label: "Completed",
apps/web/src/components/SidebarV2.tsx+28-19
......@@ -482,7 +482,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
482482 // findable. In-flight rows recede the same as read-ready ones (inbox-zero:
483483 // working threads aren't your problem yet) — only the colored status label
484484 // stands out.
485 const isInFlight = status === "working" || status === "approval" || status === "input";
485 const isInFlight =
486 status === "working" || status === "monitoring" || status === "approval" || status === "input";
486487 const shouldRecede =
487488 (status === "ready" || isInFlight) && !isUnread && !isWoke && !props.isActive && !isSelected;
488489 // Status hues follow the system-wide convention set by sidebar v1 and the
......@@ -496,37 +497,45 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
496497 className:
497498 "animate-sidebar-working-text text-sky-600 motion-reduce:animate-none dark:text-sky-400",
498499 }
499 : status === "approval"
500 : status === "monitoring"
500501 ? {
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",
502505 icon: null,
503 className: "text-amber-700 dark:text-amber-300",
506 className: "text-sky-600 dark:text-sky-400",
504507 }
505 : status === "input"
508 : status === "approval"
506509 ? {
507 label: "Input",
510 label: "Approval",
508511 icon: null,
509 className: "text-indigo-600 dark:text-indigo-300",
512 className: "text-amber-700 dark:text-amber-300",
510513 }
511 : status === "failed"
514 : status === "input"
512515 ? {
513 label: "Failed",
516 label: "Input",
514517 icon: null,
515 className: "text-red-700 dark:text-red-300",
518 className: "text-indigo-600 dark:text-indigo-300",
516519 }
517 : isWoke
520 : status === "failed"
518521 ? {
519 label: "Woke",
520 icon: "woke" as const,
521 className: "text-amber-700 dark:text-amber-300",
522 label: "Failed",
523 icon: null,
524 className: "text-red-700 dark:text-red-300",
522525 }
523 : isUnread
526 : isWoke
524527 ? {
525 label: "Done",
526 icon: "done" as const,
527 className: "text-emerald-700 dark:text-emerald-300",
528 label: "Woke",
529 icon: "woke" as const,
530 className: "text-amber-700 dark:text-amber-300",
528531 }
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
531540 const gitCwd = thread.worktreePath ?? props.projectCwd;
532541 const gitStatus = useEnvironmentQuery(
apps/web/src/components/chat/MessagesTimeline.logic.ts+37-14
......@@ -352,9 +352,16 @@ function deriveTurnFolds(input: {
352352 }
353353 const hiddenEntryIds = new Set<string>();
354354 for (const entry of group.entries) {
355 if (entry.id !== group.terminalEntry?.id) {
356 hiddenEntryIds.add(entry.id);
355 if (entry.id === group.terminalEntry?.id) {
356 continue;
357357 }
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);
358365 }
359366 if (hiddenEntryIds.size === 0) {
360367 continue;
......@@ -489,9 +496,21 @@ export function deriveMessagesTimelineRows(input: {
489496 } else {
490497 const groupId = `work-group:${timelineEntry.id}`;
491498 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
492 const hiddenEntries = visibleGroupedEntries.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
493 const visibleEntries = visibleGroupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES);
494 const renderedEntries = expanded ? [...hiddenEntries, ...visibleEntries] : visibleEntries;
499 // Agent-spawn CTA rows are always visible: a running fleet must
500 // never hide behind a "+N tool calls" toggle. Selection is by
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
496515 for (const workEntry of renderedEntries) {
497516 nextRows.push({
......@@ -502,15 +521,19 @@ export function deriveMessagesTimelineRows(input: {
502521 });
503522 }
504523
505 nextRows.push({
506 kind: "work-toggle",
507 id: `work-toggle:${timelineEntry.id}`,
508 createdAt: timelineEntry.createdAt,
509 groupId,
510 hiddenCount: hiddenEntries.length,
511 expanded,
512 onlyToolEntries: visibleGroupedEntries.every((entry) => workLogEntryIsToolLike(entry)),
513 });
524 if (hiddenEntries.length > 0) {
525 nextRows.push({
526 kind: "work-toggle",
527 id: `work-toggle:${timelineEntry.id}`,
528 createdAt: timelineEntry.createdAt,
529 groupId,
530 hiddenCount: hiddenEntries.length,
531 expanded,
532 onlyToolEntries: visibleGroupedEntries.every((entry) =>
533 workLogEntryIsToolLike(entry),
534 ),
535 });
536 }
514537 }
515538 }
516539 index = cursor - 1;
apps/web/src/components/chat/MessagesTimeline.tsx+131-1
......@@ -6,6 +6,14 @@ import {
66 type TurnId,
77} from "@t3tools/contracts";
88import { 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 = () => {};
917import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
1018import {
1119 createContext,
......@@ -135,6 +143,8 @@ interface TimelineRowSharedState {
135143 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
136144 onToggleTurnFold: (turnId: TurnId) => void;
137145 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;
146 agentPanelModel: AgentPanelModel;
147 onOpenAgents: () => void;
138148}
139149
140150interface TimelineRowActivityState {
......@@ -156,6 +166,8 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d
156166// ---------------------------------------------------------------------------
157167
158168interface MessagesTimelineProps {
169 agentPanelModel?: AgentPanelModel;
170 onOpenAgents?: () => void;
159171 isWorking: boolean;
160172 activeTurnInProgress: boolean;
161173 activeTurnStartedAt: string | null;
......@@ -194,6 +206,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
194206 isWorking,
195207 activeTurnInProgress,
196208 activeTurnStartedAt,
209 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
210 onOpenAgents = NOOP_OPEN_AGENTS,
197211 listRef,
198212 timelineEntries,
199213 latestTurn,
......@@ -430,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
430444 onOpenTurnDiff,
431445 onToggleTurnFold,
432446 onToggleWorkGroup,
447 agentPanelModel,
448 onOpenAgents,
433449 }),
434450 [
435451 timestampFormat,
......@@ -444,6 +460,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
444460 onOpenTurnDiff,
445461 onToggleTurnFold,
446462 onToggleWorkGroup,
463 agentPanelModel,
464 onOpenAgents,
447465 ],
448466 );
449467 const activityState = useMemo<TimelineRowActivityState>(
......@@ -1897,8 +1915,14 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
18971915 case "mcp_tool_call":
18981916 return "wrench";
18991917 case "dynamic_tool_call":
1900 case "collab_agent_tool_call":
19011918 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";
19021926 }
19031927
19041928 return workToneIcon(workEntry.tone).iconName;
......@@ -1921,9 +1945,115 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string {
19211945
19221946const 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
19242042const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
19252043 workEntry: TimelineWorkEntry;
19262044 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;
19272057}) {
19282058 const { workEntry, workspaceRoot } = props;
19292059 const activity = use(TimelineRowActivityCtx);
apps/web/src/rightPanelStore.ts+14-3
......@@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware";
1414
1515import { 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;
1826export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
1927
2028export type RightPanelSurface =
......@@ -37,10 +45,11 @@ export type RightPanelSurface =
3745 revealLine: number | null;
3846 revealRequestId: number;
3947 }
40 | { id: "plan"; kind: "plan" };
48 | { id: "plan"; kind: "plan" }
49 | { id: "agents"; kind: "agents" };
4150
4251const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
43const RIGHT_PANEL_STORAGE_VERSION = 7;
52const RIGHT_PANEL_STORAGE_VERSION = 8;
4453
4554export interface ThreadRightPanelState {
4655 isOpen: boolean;
......@@ -92,6 +101,8 @@ const singletonSurface = (
92101 return { id: "files", kind };
93102 case "plan":
94103 return { id: "plan", kind };
104 case "agents":
105 return { id: "agents", kind };
95106 }
96107};
97108
apps/web/src/session-logic.test.ts+226-1
......@@ -1,4 +1,5 @@
11import {
2 classifyTaskAgentKind,
23 EventId,
34 MessageId,
45 ThreadId,
......@@ -35,7 +36,19 @@ function makeActivity(overrides: {
3536 turnId?: string;
3637 sequence?: number;
3738}): 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;
3952 return {
4053 id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`),
4154 createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z",
......@@ -1686,3 +1699,215 @@ describe("deriveActiveWorkStartedAt", () => {
16861699 ).toBe("2026-02-27T21:11:00.000Z");
16871700 });
16881701});
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 @@
11import * as Option from "effect/Option";
22import * as Arr from "effect/Array";
3import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime";
34import {
45 ApprovalRequestId,
56 isToolLifecycleItemType,
......@@ -78,12 +79,30 @@ export interface WorkLogEntry {
7879 toolLifecycleStatus?: WorkLogToolLifecycleStatus;
7980 /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */
8081 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 };
8197}
8298
8399interface DerivedWorkLogEntry extends WorkLogEntry {
84100 activityKind: OrchestrationThreadActivity["kind"];
85101 collapseKey?: string;
86102 toolCallId?: string;
103 isWorkflowCoordinator?: boolean;
104 /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn CTAs. */
105 isBackgroundTask?: boolean;
87106}
88107
89108export interface PendingApproval {
......@@ -250,6 +269,12 @@ export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
250269
251270/** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */
252271export 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 }
253278 if (!workLogEntryIsToolLike(entry)) {
254279 return false;
255280 }
......@@ -624,6 +649,67 @@ export function hasActionableProposedPlan(
624649 return proposedPlan !== null && proposedPlan.implementedAt === null;
625650}
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
627713export function deriveWorkLogEntries(
628714 activities: ReadonlyArray<OrchestrationThreadActivity>,
629715): WorkLogEntry[] {
......@@ -631,10 +717,17 @@ export function deriveWorkLogEntries(
631717 const entries: DerivedWorkLogEntry[] = [];
632718 for (const activity of ordered) {
633719 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;
635727 if (activity.kind === "context-window.updated") continue;
636728 if (activity.summary === "Checkpoint captured") continue;
637729 if (isPlanBoundaryToolActivity(activity)) continue;
730 if (isAgentInternalActivity(activity)) continue;
638731 entries.push(toDerivedWorkLogEntry(activity));
639732 }
640733 return collapseDerivedWorkLogEntries(entries).map((entry) => {
......@@ -682,7 +775,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
682775 const commandPreview = extractToolCommand(payload);
683776 const changedFiles = extractChangedFiles(payload);
684777 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";
686782 const taskSummary =
687783 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
688784 ? payload.summary
......@@ -756,6 +852,22 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
756852 if (toolLifecycleStatus) {
757853 entry.toolLifecycleStatus = toolLifecycleStatus;
758854 }
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 }
759871 const collapseKey = deriveToolLifecycleCollapseKey(entry);
760872 if (collapseKey) {
761873 entry.collapseKey = collapseKey;
......@@ -763,11 +875,92 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
763875 return entry;
764876}
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
766904function collapseDerivedWorkLogEntries(
767905 entries: ReadonlyArray<DerivedWorkLogEntry>,
768906): DerivedWorkLogEntry[] {
769907 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>();
770920 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 }
771964 const previous = collapsed.at(-1);
772965 if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
773966 collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
......@@ -847,6 +1040,14 @@ function mergeChangedFiles(
8471040}
8481041
8491042function 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 }
8501051 if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
8511052 return undefined;
8521053 }
packages/client-runtime/package.json+4
......@@ -123,6 +123,10 @@
123123 "types": "./src/state/threads.ts",
124124 "default": "./src/state/threads.ts"
125125 },
126 "./state/subagentRuntime": {
127 "types": "./src/state/subagentRuntime.ts",
128 "default": "./src/state/subagentRuntime.ts"
129 },
126130 "./state/thread-sort": {
127131 "types": "./src/state/threadSort.ts",
128132 "default": "./src/state/threadSort.ts"
packages/client-runtime/src/state/orchestration.ts+7
......@@ -12,6 +12,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
1212 label: "environment-data:orchestration:turn-diff",
1313 tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
1414 }),
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 }),
1522 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
1623 label: "environment-data:orchestration:full-thread-diff",
1724 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";
2424
2525export const ORCHESTRATION_WS_METHODS = {
2626 dispatchCommand: "orchestration.dispatchCommand",
27 getWorkflowScript: "orchestration.getWorkflowScript",
2728 getTurnDiff: "orchestration.getTurnDiff",
2829 getFullThreadDiff: "orchestration.getFullThreadDiff",
2930 searchThreads: "orchestration.searchThreads",
......@@ -438,6 +439,12 @@ export const OrchestrationThreadShell = Schema.Struct({
438439 hasPendingApprovals: Schema.Boolean,
439440 hasPendingUserInput: Schema.Boolean,
440441 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"]))),
441448});
442449export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type;
443450
......@@ -1464,11 +1471,63 @@ export const OrchestrationSearchThreadsResult = Schema.Struct({
14641471});
14651472export 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
14671522export const OrchestrationRpcSchemas = {
14681523 dispatchCommand: {
14691524 input: ClientOrchestrationCommand,
14701525 output: DispatchResult,
14711526 },
1527 getWorkflowScript: {
1528 input: OrchestrationGetWorkflowScriptInput,
1529 output: OrchestrationGetWorkflowScriptResult,
1530 },
14721531 getTurnDiff: {
14731532 input: OrchestrationGetTurnDiffInput,
14741533 output: OrchestrationGetTurnDiffResult,
packages/contracts/src/providerRuntime.test.ts+20-1
......@@ -1,7 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22import * as Schema from "effect/Schema";
33
4import { ProviderRuntimeEvent } from "./providerRuntime.ts";
4import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.ts";
55
66const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
77
......@@ -182,3 +182,22 @@ describe("ProviderRuntimeEvent", () => {
182182 expect(parsed.payload.usage.usedTokens).toBe(31251);
183183 });
184184});
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([
176176 "user-input.resolved",
177177 "task.started",
178178 "task.progress",
179 "task.updated",
179180 "task.completed",
180181 "hook.started",
181182 "hook.progress",
......@@ -226,6 +227,7 @@ const UserInputRequestedType = Schema.Literal("user-input.requested");
226227const UserInputResolvedType = Schema.Literal("user-input.resolved");
227228const TaskStartedType = Schema.Literal("task.started");
228229const TaskProgressType = Schema.Literal("task.progress");
230const TaskUpdatedType = Schema.Literal("task.updated");
229231const TaskCompletedType = Schema.Literal("task.completed");
230232const HookStartedType = Schema.Literal("hook.started");
231233const HookProgressType = Schema.Literal("hook.progress");
......@@ -407,6 +409,13 @@ export const ItemLifecyclePayload = Schema.Struct({
407409 title: Schema.optional(TrimmedNonEmptyStringSchema),
408410 detail: Schema.optional(TrimmedNonEmptyStringSchema),
409411 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),
410419});
411420export type ItemLifecyclePayload = typeof ItemLifecyclePayload.Type;
412421
......@@ -459,27 +468,180 @@ const UserInputResolvedPayload = Schema.Struct({
459468});
460469export 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
462589const TaskStartedPayload = Schema.Struct({
463590 taskId: RuntimeTaskId,
464591 description: Schema.optional(TrimmedNonEmptyStringSchema),
465 taskType: Schema.optional(TrimmedNonEmptyStringSchema),
592 ...taskAgentLinkageFields,
466593});
467594export 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
469608const TaskProgressPayload = Schema.Struct({
470609 taskId: RuntimeTaskId,
471610 description: TrimmedNonEmptyStringSchema,
472611 summary: Schema.optional(TrimmedNonEmptyStringSchema),
473612 usage: Schema.optional(Schema.Unknown),
613 typedUsage: Schema.optional(RuntimeTaskUsage),
474614 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,
475619});
476620export 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
478638const TaskCompletedPayload = Schema.Struct({
479639 taskId: RuntimeTaskId,
480640 status: Schema.Literals(["completed", "failed", "stopped"]),
481641 summary: Schema.optional(TrimmedNonEmptyStringSchema),
482642 usage: Schema.optional(Schema.Unknown),
643 typedUsage: Schema.optional(RuntimeTaskUsage),
644 ...taskAgentLinkageFields,
483645});
484646export type TaskCompletedPayload = typeof TaskCompletedPayload.Type;
485647
......@@ -513,6 +675,9 @@ const ToolProgressPayload = Schema.Struct({
513675 toolName: Schema.optional(TrimmedNonEmptyStringSchema),
514676 summary: Schema.optional(TrimmedNonEmptyStringSchema),
515677 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),
516681});
517682export type ToolProgressPayload = typeof ToolProgressPayload.Type;
518683
......@@ -835,6 +1000,13 @@ const ProviderRuntimeTaskProgressEvent = Schema.Struct({
8351000});
8361001export 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
8381010const ProviderRuntimeTaskCompletedEvent = Schema.Struct({
8391011 ...ProviderRuntimeEventBase.fields,
8401012 type: TaskCompletedType,
......@@ -995,6 +1167,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([
9951167 ProviderRuntimeUserInputResolvedEvent,
9961168 ProviderRuntimeTaskStartedEvent,
9971169 ProviderRuntimeTaskProgressEvent,
1170 ProviderRuntimeTaskUpdatedEvent,
9981171 ProviderRuntimeTaskCompletedEvent,
9991172 ProviderRuntimeHookStartedEvent,
10001173 ProviderRuntimeHookProgressEvent,
packages/contracts/src/rpc.ts+11
......@@ -64,6 +64,7 @@ import {
6464 OrchestrationGetTurnDiffError,
6565 OrchestrationGetTurnDiffInput,
6666 OrchestrationRpcSchemas,
67 OrchestrationGetWorkflowScriptError,
6768} from "./orchestration.ts";
6869import { ProviderInstanceId } from "./providerInstance.ts";
6970import {
......@@ -696,6 +697,15 @@ export const WsOrchestrationDispatchCommandRpc = Rpc.make(
696697 },
697698);
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
699709export const WsOrchestrationGetTurnDiffRpc = Rpc.make(ORCHESTRATION_WS_METHODS.getTurnDiff, {
700710 payload: OrchestrationGetTurnDiffInput,
701711 success: OrchestrationRpcSchemas.getTurnDiff.output,
......@@ -867,6 +877,7 @@ export const WsRpcGroup = RpcGroup.make(
867877 WsSubscribeBackgroundPolicyRpc,
868878 WsSubscribeResourceTelemetryRpc,
869879 WsOrchestrationDispatchCommandRpc,
880 WsOrchestrationGetWorkflowScriptRpc,
870881 WsOrchestrationGetTurnDiffRpc,
871882 WsOrchestrationGetFullThreadDiffRpc,
872883 WsOrchestrationSearchThreadsRpc,