1/**
2 * Subagent status helpers shared by web and mobile, and the runtime shape the
3 * web agent rows render.
4 */
5import { groupBy } from "effect/Array";
6import * as DateTime from "effect/DateTime";
7import type {
8 OrchestrationV2Subagent,
9 OrchestrationV2SubagentWorkflow,
10 OrchestrationV2TurnItem,
11} from "@t3tools/contracts";
12import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
13
14export type RuntimeSubagentStatus =
15 | "pending"
16 | "running"
17 | "waiting"
18 | "idle"
19 | "completed"
20 | "failed"
21 | "cancelled"
22 | "interrupted";
23
24export interface SubagentUsage {
25 readonly totalTokens: number;
26 readonly inputTokens?: number;
27 readonly cachedInputTokens?: number;
28 readonly outputTokens?: number;
29 readonly reasoningOutputTokens?: number;
30 readonly toolUses?: number;
31 readonly durationMs?: number;
32}
33
34export interface SubagentActivityEntry {
35 readonly at: string;
36 readonly summary: string;
37}
38
39export interface SubagentWorkflowPhase {
40 readonly index: number;
41 readonly title: string;
42}
43
44// Optional-and-undefined, matching the contract's own optionals.
45export interface SubagentRunHandles {
46 readonly runId?: string | undefined;
47 readonly scriptPath?: string | undefined;
48 readonly transcriptDir?: string | undefined;
49 readonly sessionUrl?: string | undefined;
50}
51
52export interface RuntimeSubagent {
53 readonly id: string;
54 readonly kind: "subagent" | "subagent_batch" | "workflow" | "workflow_agent";
55 readonly title: string;
56 readonly role: string | null;
57 readonly model: string | null;
58 readonly effort: string | null;
59 readonly status: RuntimeSubagentStatus;
60 readonly activationCount: number;
61 readonly usage: SubagentUsage | null;
62 readonly progress: string | null;
63 readonly lastToolName: string | null;
64 readonly result: string | null;
65 readonly error: string | null;
66 readonly outputFile: string | null;
67 readonly parentAgentId: string | null;
68 readonly agentIndex: number | null;
69 readonly phaseIndex: number | null;
70 readonly phaseTitle: string | null;
71 readonly attempt: number | null;
72 readonly workflowName: string | null;
73 readonly phases: ReadonlyArray<SubagentWorkflowPhase>;
74 readonly runHandles: SubagentRunHandles | null;
75 /** The thread this agent owns, when it has one. */
76 readonly childThreadId: string | null;
77 readonly recentActivity: ReadonlyArray<SubagentActivityEntry>;
78 /** First retained observation, used as the roster's stable display order. */
79 readonly firstSeenAt: string;
80 readonly startedAt: string | null;
81 readonly completedAt: string | null;
82 readonly updatedAt: string;
83}
84
85const TERMINAL_STATUSES: ReadonlySet<RuntimeSubagentStatus> = new Set([
86 "completed",
87 "failed",
88 "cancelled",
89 "interrupted",
90]);
91
92export function isTerminalSubagentStatus(status: RuntimeSubagentStatus): boolean {
93 return TERMINAL_STATUSES.has(status);
94}
95
96/** Active = the user may still need to care while it runs. Idle is settled-ish
97 * but resumable; waiting counts as active because it needs the user. */
98export function isActiveSubagentStatus(status: RuntimeSubagentStatus): boolean {
99 return isOrchestrationV2WorkActive(status);
100}
101
102export function isLiveSubagentTurnItem(item: OrchestrationV2TurnItem): boolean {
103 return item.type === "subagent" && isOrchestrationV2WorkActive(item.status);
104}
105
106/** A workflow coordinator with its members grouped by phase, as Lineage renders it. */
107export interface SubagentWorkflowGroup {
108 readonly workflow: RuntimeSubagent;
109 readonly phases: ReadonlyArray<{
110 readonly index: number;
111 readonly title: string;
112 readonly members: ReadonlyArray<RuntimeSubagent>;
113 /** done = every member settled (success or error); running = any active. */
114 readonly state: "pending" | "running" | "done";
115 readonly settledCount: number;
116 }>;
117 /** Members with no resolvable phase (orphans render under the workflow). */
118 readonly unphasedMembers: ReadonlyArray<RuntimeSubagent>;
119}
120
121function workflowUsage(source: {
122 readonly totalTokens?: number | undefined;
123 readonly toolCalls?: number | undefined;
124 readonly durationMs?: number | undefined;
125}): SubagentUsage | null {
126 if (source.totalTokens === undefined) return null;
127 return {
128 totalTokens: source.totalTokens,
129 ...(source.toolCalls === undefined ? {} : { toolUses: source.toolCalls }),
130 ...(source.durationMs === undefined ? {} : { durationMs: source.durationMs }),
131 };
132}
133
134function isoFromEpochMillis(value: number | undefined): string | null {
135 return value === undefined ? null : DateTime.formatIso(DateTime.makeUnsafe(value));
136}
137
138/**
139 * Expands a coordinator's workflow roster into member rows. Members are
140 * synthesized from the coordinator's snapshot rather than read from their own
141 * subagent entities, so a parent thread's Lineage can show them without
142 * loading the coordinator's child thread; their identity is the coordinator id
143 * plus the spawn ordinal.
144 */
145function workflowMembersToRuntime(
146 coordinator: RuntimeSubagent,
147 workflow: OrchestrationV2SubagentWorkflow,
148): ReadonlyArray<RuntimeSubagent> {
149 return workflow.agents.map((agent) => {
150 // Every member state but queued is already a runtime status.
151 const status =
152 (agent.state === "queued" || agent.state === "running") &&
153 isTerminalSubagentStatus(coordinator.status)
154 ? coordinator.status
155 : agent.state === "queued"
156 ? "pending"
157 : agent.state;
158 const failed = status === "failed";
159 const startedAt = isoFromEpochMillis(agent.startedAt);
160 // The provider reports a settled member's duration, not its end instant.
161 const completedAt = isTerminalSubagentStatus(status)
162 ? agent.startedAt !== undefined && agent.durationMs !== undefined
163 ? isoFromEpochMillis(agent.startedAt + agent.durationMs)
164 : coordinator.completedAt
165 : null;
166 return {
167 // Members share the run's handles: the transcript directory is what
168 // makes their conversation readable, and only the run knows it.
169 ...coordinator,
170 id: `${coordinator.id}:agent:${agent.index}`,
171 kind: "workflow_agent" as const,
172 title: agent.label,
173 model: agent.model ?? null,
174 status,
175 // A member the workflow retried counts each attempt as a run.
176 activationCount: agent.attempt ?? 1,
177 usage: workflowUsage(agent),
178 // A member keeps its prompt on `progress` even once settled: that is the
179 // question half of its conversation, and the detail view shows both halves.
180 progress: agent.prompt ?? null,
181 result: failed ? null : (agent.result ?? null),
182 error: failed ? (agent.result ?? null) : null,
183 parentAgentId: coordinator.id,
184 agentIndex: agent.index,
185 phaseIndex: agent.phaseIndex ?? null,
186 phaseTitle: agent.phaseTitle ?? null,
187 attempt: agent.attempt ?? null,
188 phases: [],
189 childThreadId: agent.childThreadId ?? null,
190 recentActivity: [],
191 firstSeenAt: isoFromEpochMillis(agent.queuedAt) ?? startedAt ?? coordinator.firstSeenAt,
192 startedAt,
193 completedAt,
194 updatedAt: completedAt ?? startedAt ?? coordinator.firstSeenAt,
195 } satisfies RuntimeSubagent;
196 });
197}
198
199/**
200 * Projects subagents and workflow members into the runtime roster.
201 */
202export function projectedSubagentsToRuntime(
203 subagents: ReadonlyArray<{
204 readonly id: string;
205 readonly title: string | null;
206 readonly prompt: string;
207 readonly model: string | null;
208 readonly status: OrchestrationV2Subagent["status"];
209 readonly progress?: string | undefined;
210 readonly childThreadId?: string | null | undefined;
211 readonly workflow?: OrchestrationV2SubagentWorkflow | undefined;
212 readonly result: string | null;
213 readonly startedAt: DateTime.Utc | null;
214 readonly completedAt: DateTime.Utc | null;
215 readonly updatedAt: DateTime.Utc;
216 }>,
217): ReadonlyArray<RuntimeSubagent> {
218 return subagents.flatMap((subagent) => {
219 const updatedAt = DateTime.formatIso(subagent.updatedAt);
220 const startedAt = subagent.startedAt === null ? null : DateTime.formatIso(subagent.startedAt);
221 const firstSeenAt = startedAt ?? updatedAt;
222 const { workflow } = subagent;
223 const runHandles = workflow?.runHandles ?? null;
224 const coordinator = {
225 id: subagent.id,
226 kind: workflow === undefined ? "subagent" : "workflow",
227 title:
228 subagent.title ??
229 (subagent.prompt.length > 80 ? `${subagent.prompt.slice(0, 77)}...` : subagent.prompt),
230 role: null,
231 model: subagent.model,
232 effort: null,
233 status: subagent.status,
234 activationCount: 1,
235 usage: workflow === undefined ? null : workflowUsage(workflow),
236 progress: subagent.progress ?? null,
237 lastToolName: null,
238 result: subagent.result,
239 error: subagent.status === "failed" ? (subagent.result ?? null) : null,
240 outputFile: null,
241 parentAgentId: null,
242 agentIndex: null,
243 phaseIndex: null,
244 phaseTitle: null,
245 attempt: null,
246 workflowName: workflow?.name ?? null,
247 phases: workflow?.phases ?? [],
248 runHandles,
249 childThreadId: subagent.childThreadId ?? null,
250 recentActivity: [],
251 firstSeenAt,
252 startedAt,
253 completedAt: subagent.completedAt === null ? null : DateTime.formatIso(subagent.completedAt),
254 updatedAt,
255 } satisfies RuntimeSubagent;
256 return workflow === undefined
257 ? [coordinator]
258 : [coordinator, ...workflowMembersToRuntime(coordinator, workflow)];
259 });
260}
261
262export function deriveWorkflowGroups(
263 agents: ReadonlyArray<RuntimeSubagent>,
264): ReadonlyArray<SubagentWorkflowGroup> {
265 const workflows = agents
266 .filter((agent) => agent.kind === "workflow")
267 .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id));
268 const members = groupBy(
269 agents.filter((agent) => agent.kind !== "workflow" && agent.parentAgentId !== null),
270 (agent) => agent.parentAgentId!,
271 );
272
273 return workflows.map((workflow) => {
274 const workflowMembers = Object.hasOwn(members, workflow.id) ? members[workflow.id]! : [];
275 // Union, not either/or: the declared plan lags the members, because the
276 // provider only admits a phase once something in it starts. Members seed
277 // the map; the declared titles then overwrite whatever they guessed.
278 const phaseTitles = new Map<number, string>();
279 for (const member of workflowMembers) {
280 if (member.phaseIndex === null) continue;
281 phaseTitles.set(
282 member.phaseIndex,
283 member.phaseTitle ?? phaseTitles.get(member.phaseIndex) ?? `Phase ${member.phaseIndex + 1}`,
284 );
285 }
286 for (const phase of workflow.phases) phaseTitles.set(phase.index, phase.title);
287 const knownPhases = Array.from(phaseTitles.entries())
288 .map(([index, title]) => ({ index, title }))
289 .sort((a, b) => a.index - b.index);
290
291 const phases = knownPhases.map((phase) => {
292 const phaseMembers = workflowMembers
293 .filter((member) => member.phaseIndex === phase.index)
294 .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
295 const hasActiveMember = phaseMembers.some(
296 // Idle members count as active for phase-liveness: a resumable Codex
297 // member has not finished the phase.
298 (member) => isActiveSubagentStatus(member.status) || member.status === "idle",
299 );
300 const settledCount = phaseMembers.filter((member) =>
301 isTerminalSubagentStatus(member.status),
302 ).length;
303 const state: "pending" | "running" | "done" =
304 phaseMembers.length === 0 ? "pending" : hasActiveMember ? "running" : "done";
305 return {
306 index: phase.index,
307 title: phase.title,
308 members: phaseMembers,
309 state,
310 settledCount,
311 };
312 });
313
314 const unphasedMembers = workflowMembers
315 .filter((member) => member.phaseIndex === null)
316 .sort((a, b) => (a.agentIndex ?? 0) - (b.agentIndex ?? 0));
317
318 return { workflow, phases, unphasedMembers };
319 });
320}