1import {
2 latestRootProviderFailure,
3 latestUnheldRun,
4 threadErrorSummary,
5 usageLimitRunPresentedAsLatest,
6} from "@t3tools/shared/orchestrationV2ThreadError";
7import {
8 isOrchestrationV2WorkActive,
9 isProviderNativeSubagentThread,
10 type ModelSelection,
11 type OrchestrationV2NotificationSource,
12 type OrchestrationV2PendingBackgroundTask,
13 type OrchestrationV2ProviderGoal,
14 type ServerProviderModel,
15 type OrchestrationV2ExecutionNode,
16 type OrchestrationV2ThreadProjection,
17 orchestrationV2RunWorkStartedAt,
18 type ThreadId,
19} from "@t3tools/contracts";
20import {
21 backgroundWorkHoldsCompletion,
22 derivePendingBackgroundWork,
23} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
24import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model";
25import { formatDuration } from "@t3tools/shared/orchestrationTiming";
26import * as DateTime from "effect/DateTime";
27
28import {
29 threadRuntimeIsActive,
30 type ThreadRunSummary,
31 type ThreadRuntimeSummary,
32} from "./models.ts";
33import { formatSubagentDisplayTitle } from "./subagentDisplay.ts";
34
35const ACTIVITY_RUN_STATUSES = new Set(["preparing", "starting", "running", "waiting"]);
36const INTERRUPTIBLE_RUN_STATUSES = new Set(["preparing", "starting", "running"]);
37
38function latestMatchingRun(
39 projection: OrchestrationV2ThreadProjection,
40 predicate: (run: OrchestrationV2ThreadProjection["runs"][number]) => boolean,
41): OrchestrationV2ThreadProjection["runs"][number] | null {
42 return projection.runs.reduce<OrchestrationV2ThreadProjection["runs"][number] | null>(
43 (latest, candidate) =>
44 predicate(candidate) && (latest === null || candidate.ordinal > latest.ordinal)
45 ? candidate
46 : latest,
47 null,
48 );
49}
50
51function summarizeThreadRun(
52 projection: OrchestrationV2ThreadProjection,
53 run: OrchestrationV2ThreadProjection["runs"][number],
54): ThreadRunSummary {
55 return {
56 runId: run.id,
57 status: run.status,
58 requestedAt: DateTime.formatIso(run.requestedAt),
59 startedAt: run.startedAt === null ? null : DateTime.formatIso(run.startedAt),
60 completedAt: run.completedAt === null ? null : DateTime.formatIso(run.completedAt),
61 assistantMessageId:
62 projection.messages.findLast(
63 (message) => message.runId === run.id && message.role === "assistant",
64 )?.id ?? null,
65 ...(run.sourcePlanRef === undefined ? {} : { sourcePlanRef: run.sourcePlanRef }),
66 };
67}
68
69function presentedUsageLimitRun(
70 projection: OrchestrationV2ThreadProjection,
71): OrchestrationV2ThreadProjection["runs"][number] | null {
72 const providerSession = projection.providerSessions.findLast(
73 (session) => session.providerInstanceId === projection.thread.providerInstanceId,
74 );
75 return usageLimitRunPresentedAsLatest(
76 projection.runs,
77 projection.turnItems,
78 providerSession?.lastError ?? null,
79 );
80}
81
82/** The run that stands for the thread's outcome when no run is executing. */
83function presentedLatestRun(
84 projection: OrchestrationV2ThreadProjection,
85): OrchestrationV2ThreadProjection["runs"][number] | null {
86 return presentedUsageLimitRun(projection) ?? latestUnheldRun(projection.runs);
87}
88
89export function deriveLatestThreadRun(
90 projection: OrchestrationV2ThreadProjection,
91): ThreadRunSummary | null {
92 const run = presentedLatestRun(projection);
93 return run === null ? null : summarizeThreadRun(projection, run);
94}
95
96/**
97 * Returns the run that owns live provider work, falling back to the newest run
98 * once the thread is idle. A newer queued run must not make an older executing
99 * run look settled in clients that render per-run activity.
100 */
101export function deriveThreadActivityRun(
102 projection: OrchestrationV2ThreadProjection,
103): ThreadRunSummary | null {
104 const run =
105 latestMatchingRun(projection, (candidate) => ACTIVITY_RUN_STATUSES.has(candidate.status)) ??
106 presentedLatestRun(projection);
107 return run === null ? null : summarizeThreadRun(projection, run);
108}
109
110/**
111 * Provider-native subagent threads never get app runs: their work is a runless
112 * root turn whose status follows the subagent. Returns when that work started
113 * while it is still active, so clients can show the same working state (and
114 * timer) as a run. Stop, queue, and steer stay run-only.
115 */
116export function deriveRunlessWorkStartedAt(
117 projection: OrchestrationV2ThreadProjection,
118): string | null {
119 const status = deriveProviderSubagentStatus(projection);
120 return status !== null && isOrchestrationV2WorkActive(status.status) ? status.startedAt : null;
121}
122
123export interface ProviderSubagentStatus {
124 readonly status: OrchestrationV2ExecutionNode["status"];
125 readonly startedAt: string | null;
126 readonly completedAt: string | null;
127}
128
129/**
130 * Status of a provider-native subagent thread (see
131 * isProviderNativeSubagentThread), read from its runless root turn. Null
132 * until that root turn arrives, and for every other thread.
133 */
134export function deriveProviderSubagentStatus(
135 projection: OrchestrationV2ThreadProjection,
136): ProviderSubagentStatus | null {
137 if (!isProviderNativeSubagentThread(projection.thread)) return null;
138 const node = projection.nodes.findLast(
139 (candidate) => candidate.kind === "root_turn" && candidate.runId === null,
140 );
141 if (node === undefined) return null;
142 return {
143 status: node.status,
144 startedAt: node.startedAt === null ? null : DateTime.formatIso(node.startedAt),
145 completedAt: node.completedAt === null ? null : DateTime.formatIso(node.completedAt),
146 };
147}
148
149/** The observed selection belongs to the active provider thread, never a previous handoff. */
150export function deriveReportedModelSelection(
151 projection: OrchestrationV2ThreadProjection,
152): ModelSelection | null {
153 const providerThread = projection.providerThreads.find(
154 (candidate) =>
155 candidate.id === projection.thread.activeProviderThreadId &&
156 candidate.providerInstanceId === projection.thread.modelSelection.instanceId,
157 );
158 return providerThread?.nativeMetadata?.modelSelection ?? null;
159}
160
161// Option ids providers use for reasoning effort (Codex, Claude, Grok/ACP, OpenCode).
162const REASONING_EFFORT_OPTION_IDS = ["reasoningEffort", "effort", "reasoning", "variant"] as const;
163
164/**
165 * The reasoning effort a thread's model runs at, resolved and named the way
166 * the composer's effort picker does: the stored choice when valid, else the
167 * descriptor's current value, else the model's default. Null when the
168 * provider catalog has no effort option for this model (a subagent on a
169 * model the catalog does not describe), rather than guessing.
170 */
171export function formatModelSelectionEffort(
172 selection: ModelSelection,
173 models: ReadonlyArray<ServerProviderModel> = [],
174 reportedSelection?: ModelSelection | null,
175): string | null {
176 const caps = models.find((model) => model.slug === selection.model)?.capabilities;
177 if (!caps) return null;
178 const descriptors = getProviderOptionDescriptors({ caps, selections: selection.options });
179 for (const id of REASONING_EFFORT_OPTION_IDS) {
180 const descriptor = descriptors.find((candidate) => candidate.id === id);
181 if (descriptor?.type !== "select") continue;
182 const label = getProviderOptionCurrentLabel(descriptor, selection, reportedSelection);
183 if (label) return label;
184 }
185 return null;
186}
187
188const SUBAGENT_STATUS_LABELS: Record<OrchestrationV2ExecutionNode["status"], string> = {
189 idle: "Idle",
190 pending: "Working",
191 running: "Working",
192 waiting: "Waiting",
193 completed: "Completed",
194 interrupted: "Interrupted",
195 failed: "Failed",
196 cancelled: "Cancelled",
197 rolled_back: "Cancelled",
198};
199
200/**
201 * One line for the read-only subagent bar: "Working 12s", "Completed in 34s",
202 * or just the status when no duration is known.
203 */
204export function formatProviderSubagentStatus(
205 status: ProviderSubagentStatus | null,
206 nowMs: number,
207): string {
208 if (status === null) return "Starting";
209 const label = SUBAGENT_STATUS_LABELS[status.status];
210 const live = isOrchestrationV2WorkActive(status.status);
211 if (!live && status.status !== "completed") return label;
212 const start = status.startedAt === null ? Number.NaN : Date.parse(status.startedAt);
213 const end = live
214 ? nowMs
215 : status.completedAt === null
216 ? Number.NaN
217 : Date.parse(status.completedAt);
218 if (!Number.isFinite(start) || !Number.isFinite(end)) return label;
219 // Whole seconds: a ticking label must not flicker through tenths.
220 const elapsed = formatDuration(Math.max(1_000, Math.floor((end - start) / 1_000) * 1_000));
221 return live ? `${label} ${elapsed}` : `${label} in ${elapsed}`;
222}
223
224export function deriveThreadRuntime(
225 projection: OrchestrationV2ThreadProjection,
226): ThreadRuntimeSummary | null {
227 const latestRun = deriveLatestThreadRun(projection);
228 const providerSession = projection.providerSessions.findLast(
229 (session) => session.providerInstanceId === projection.thread.providerInstanceId,
230 );
231 const usageLimitedRun = presentedUsageLimitRun(projection);
232 const latestRunProjection = presentedLatestRun(projection);
233 const activityRun = deriveThreadActivityRun(projection);
234 const liveActivityRun = latestMatchingRun(projection, (run) =>
235 ACTIVITY_RUN_STATUSES.has(run.status),
236 );
237 // Same rule as the shell runtime: only background work that holds the
238 // completion parks the thread at idle; a dev server left running does not.
239 const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion(
240 derivePendingBackgroundWork({
241 latestRun: latestRunProjection,
242 providerThreads: projection.providerThreads,
243 turnItems: projection.turnItems,
244 activeProviderThreadId: projection.thread.activeProviderThreadId,
245 runs: projection.runs,
246 pullRequests: projection.thread.pullRequests,
247 }),
248 );
249 // A pull request watch can hold a thread that never ran.
250 if (
251 latestRun === null &&
252 projection.thread.activeProviderThreadId === null &&
253 !backgroundWorkHoldsRun
254 ) {
255 return null;
256 }
257 const activeRunId =
258 latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null;
259 return {
260 status: usageLimitedRun
261 ? "failed"
262 : backgroundWorkHoldsRun && latestRunProjection?.status !== "failed"
263 ? "idle"
264 : (activityRun?.status ?? "idle"),
265 activeRunId,
266 activityStartedAt:
267 liveActivityRun === null
268 ? null
269 : DateTime.formatIso(orchestrationV2RunWorkStartedAt(liveActivityRun)),
270 providerInstanceId: projection.thread.providerInstanceId,
271 providerName: providerSession?.driver ?? null,
272 ...threadErrorSummary(
273 latestRootProviderFailure(latestRunProjection, projection.turnItems),
274 providerSession?.lastError ?? null,
275 ),
276 updatedAt: DateTime.formatIso(projection.updatedAt),
277 };
278}
279
280export function threadRuntimeHasInterruptibleRun(
281 runtime: ThreadRuntimeSummary | null | undefined,
282): boolean {
283 return (
284 threadRuntimeIsActive(runtime) &&
285 runtime?.activeRunId !== null &&
286 runtime?.activeRunId !== undefined
287 );
288}
289
290type BackgroundWorkKind = OrchestrationV2PendingBackgroundTask["kind"];
291
292// `order` groups work the way a reader thinks about it: agents first, loose tasks last.
293const BACKGROUND_WORK_KINDS: Record<
294 BackgroundWorkKind,
295 { readonly order: number; readonly singular: string; readonly plural: string }
296> = {
297 subagent: { order: 0, singular: "subagent", plural: "subagents" },
298 command: { order: 1, singular: "command", plural: "commands" },
299 monitor: { order: 2, singular: "monitor", plural: "monitors" },
300 background_task: { order: 3, singular: "background task", plural: "background tasks" },
301};
302
303export interface PendingBackgroundWorkItem {
304 readonly taskId: string;
305 readonly kind: BackgroundWorkKind;
306 /** The work's name, or its noun when the provider gave none. */
307 readonly label: string;
308 /** A subagent's own thread, when it has one. */
309 readonly childThreadId: ThreadId | undefined;
310}
311
312export interface PendingBackgroundWorkPresentation {
313 /**
314 * "Waiting on subagent Review src/math.ts", "Waiting on 2 subagents and 1 command",
315 * or "Running: Start the dev server" when only commands remain.
316 */
317 readonly title: string;
318 readonly items: ReadonlyArray<PendingBackgroundWorkItem>;
319 /**
320 * True when the work will wake the agent (subagents, monitors). False when
321 * only commands remain, such as a dev server: the agent is done.
322 */
323 readonly waiting: boolean;
324}
325
326function joinWithAnd(parts: ReadonlyArray<string>): string {
327 if (parts.length <= 1) return parts.join("");
328 return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
329}
330
331/** Names what a settled thread still runs, grouped by kind, for the composer strip. */
332export function presentPendingBackgroundWork(
333 tasks: ReadonlyArray<OrchestrationV2PendingBackgroundTask>,
334): PendingBackgroundWorkPresentation | null {
335 if (tasks.length === 0) return null;
336 const waiting = backgroundWorkHoldsCompletion(tasks);
337 const items = tasks
338 .map((task): PendingBackgroundWorkItem => {
339 const description = task.description?.trim();
340 const label =
341 task.kind === "subagent" && description !== undefined
342 ? formatSubagentDisplayTitle(description).trim()
343 : description;
344 return {
345 taskId: task.taskId,
346 kind: task.kind,
347 label:
348 label === undefined || label.length === 0
349 ? BACKGROUND_WORK_KINDS[task.kind].singular
350 : label,
351 childThreadId: task.kind === "subagent" ? task.childThreadId : undefined,
352 };
353 })
354 // `map` returned a new array; Hermes has no `toSorted`.
355 .sort(
356 (left, right) =>
357 BACKGROUND_WORK_KINDS[left.kind].order - BACKGROUND_WORK_KINDS[right.kind].order,
358 );
359 const [only] = items;
360 if (items.length === 1 && only !== undefined) {
361 const noun = BACKGROUND_WORK_KINDS[only.kind].singular;
362 const named = only.label !== noun;
363 const title = waiting
364 ? named
365 ? `${only.kind === "monitor" ? "" : `Waiting on ${noun} `}${only.label}`
366 : `Waiting on a ${noun}`
367 : named
368 ? `Running: ${only.label}`
369 : `Running a ${noun}`;
370 return { title, items, waiting };
371 }
372 const counts = new Map<BackgroundWorkKind, number>();
373 for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
374 const groups = Array.from(counts, ([kind, count]) => {
375 const { singular, plural } = BACKGROUND_WORK_KINDS[kind];
376 return `${count} ${count === 1 ? singular : plural}`;
377 });
378 return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting };
379}
380
381export interface ProviderGoalPresentation {
382 /** "Pursuing goal", "Goal paused", "Goal complete"... */
383 readonly title: string;
384 readonly objective: string;
385 /** "12k / 50k tokens · 4m" for Codex, "2 checks" for Claude; null when nothing is reported. */
386 readonly usage: string | null;
387 /** Only Codex stops a goal short of done, and only a stopped goal can resume. */
388 readonly canResume: boolean;
389}
390
391const PROVIDER_GOAL_TITLES: Record<OrchestrationV2ProviderGoal["status"], string> = {
392 active: "Pursuing goal",
393 paused: "Goal paused",
394 blocked: "Goal blocked",
395 usage_limited: "Goal hit a usage limit",
396 budget_limited: "Goal reached its token budget",
397 complete: "Goal complete",
398};
399
400function formatGoalTokens(tokens: number): string {
401 if (tokens < 1_000) return `${tokens}`;
402 if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`;
403 return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`;
404}
405
406/**
407 * Status line for a native `/goal`, shared by the web composer row and mobile.
408 * An active goal on an idle thread (Claude after Stop) is set, not pursued.
409 */
410export function presentProviderGoal(
411 goal: OrchestrationV2ProviderGoal,
412 working: boolean,
413): ProviderGoalPresentation {
414 const usage: Array<string> = [];
415 if (goal.tokensUsed !== undefined && goal.tokensUsed > 0) {
416 usage.push(
417 goal.tokenBudget == null
418 ? `${formatGoalTokens(goal.tokensUsed)} tokens`
419 : `${formatGoalTokens(goal.tokensUsed)} / ${formatGoalTokens(goal.tokenBudget)} tokens`,
420 );
421 }
422 if (goal.timeUsedSeconds !== undefined && goal.timeUsedSeconds >= 60) {
423 usage.push(formatDuration(goal.timeUsedSeconds * 1_000));
424 }
425 if (goal.checks !== undefined && goal.checks > 0) {
426 usage.push(`${goal.checks} ${goal.checks === 1 ? "check" : "checks"}`);
427 }
428 return {
429 title: goal.status === "active" && !working ? "Goal set" : PROVIDER_GOAL_TITLES[goal.status],
430 objective: goal.objective,
431 usage: usage.length === 0 ? null : usage.join(" · "),
432 canResume: goal.status !== "active" && goal.status !== "complete",
433 };
434}
435
436/** The thread a notification row opens: that of the one subagent or delegated task it reports. */
437export function notificationChildThreadId(
438 source: OrchestrationV2NotificationSource,
439): ThreadId | undefined {
440 switch (source.kind) {
441 case "subagent":
442 case "delegated_task":
443 return source.childThreadId;
444 case "command":
445 case "monitor":
446 case "background_task":
447 case "system":
448 return undefined;
449 default:
450 source satisfies never;
451 return undefined;
452 }
453}