| 1 | import { |
| 2 | latestRootProviderFailure, |
| 3 | latestUnheldRun, |
| 4 | threadErrorSummary, |
| 5 | usageLimitRunPresentedAsLatest, |
| 6 | } from "@t3tools/shared/orchestrationV2ThreadError"; |
| 7 | import { |
| 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"; |
| 20 | import { |
| 21 | backgroundWorkHoldsCompletion, |
| 22 | derivePendingBackgroundWork, |
| 23 | } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 24 | import { getProviderOptionCurrentLabel, getProviderOptionDescriptors } from "@t3tools/shared/model"; |
| 25 | import { formatDuration } from "@t3tools/shared/orchestrationTiming"; |
| 26 | import * as DateTime from "effect/DateTime"; |
| 27 | |
| 28 | import { |
| 29 | threadRuntimeIsActive, |
| 30 | type ThreadRunSummary, |
| 31 | type ThreadRuntimeSummary, |
| 32 | } from "./models.ts"; |
| 33 | import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; |
| 34 | |
| 35 | const ACTIVITY_RUN_STATUSES = new Set(["preparing", "starting", "running", "waiting"]); |
| 36 | const INTERRUPTIBLE_RUN_STATUSES = new Set(["preparing", "starting", "running"]); |
| 37 | |
| 38 | function 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 | |
| 51 | function 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 | |
| 69 | function 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. */ |
| 83 | function presentedLatestRun( |
| 84 | projection: OrchestrationV2ThreadProjection, |
| 85 | ): OrchestrationV2ThreadProjection["runs"][number] | null { |
| 86 | return presentedUsageLimitRun(projection) ?? latestUnheldRun(projection.runs); |
| 87 | } |
| 88 | |
| 89 | export 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 | */ |
| 101 | export 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 | */ |
| 116 | export 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 | |
| 123 | export 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 | */ |
| 134 | export 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. */ |
| 150 | export 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). |
| 162 | const 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 | */ |
| 171 | export 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 | |
| 188 | const 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 | */ |
| 204 | export 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 | |
| 224 | export 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 | |
| 280 | export 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 | |
| 290 | type BackgroundWorkKind = OrchestrationV2PendingBackgroundTask["kind"]; |
| 291 | |
| 292 | // `order` groups work the way a reader thinks about it: agents first, loose tasks last. |
| 293 | const 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 | |
| 303 | export 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 | |
| 312 | export 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 | |
| 326 | function 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. */ |
| 332 | export 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 | |
| 381 | export 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 | |
| 391 | const 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 | |
| 400 | function 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 | */ |
| 410 | export 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. */ |
| 437 | export 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 | } |