Open History to see the last commit
1import {
2 isListedThreadLineage,
3 type EnvironmentId,
4 isProviderNativeSubagentThread,
5 type OrchestrationV2ThreadShell,
6 type Project,
7 type RuntimeRequestId,
8 type ThreadId,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11
12import {
13 backgroundWorkHoldsCompletion,
14 monitorHoldsWakeCompletion,
15} from "./orchestrationV2PendingBackgroundWork.ts";
16
17export type AgentAwarenessPhase =
18 | "starting"
19 | "running"
20 | "waiting_for_approval"
21 | "waiting_for_input"
22 | "completed"
23 | "failed"
24 | "stale";
25
26export interface SubagentRequestAwareness {
27 readonly requestId: RuntimeRequestId;
28 /** The child's own update time, which moves whenever its request changes. */
29 readonly updatedAt: DateTime.Utc;
30 /** A card only the user can answer, rather than an approval. */
31 readonly waitsForInput?: boolean;
32}
33
34/**
35 * Turn items that can start or end a subagent's user-only wait: its secret or link card,
36 * or a user message that moves past a link card. Filters that drop subagent transcript
37 * updates must keep these, or the parent's "Input needed" never appears or never clears.
38 */
39export const turnItemCanChangeUserWait = (item: { readonly type: string }) =>
40 item.type === "secret_request" || item.type === "link_request" || item.type === "user_message";
41
42/**
43 * Provider-native subagents ask on their parent. The coordinator answers child questions,
44 * but not a card only the user can answer, such as a secret.
45 */
46export function subagentAwaitsUser(
47 thread: Pick<
48 OrchestrationV2ThreadShell,
49 "activityRunStatus" | "creationSource" | "lineage" | "pendingRuntimeRequest"
50 >,
51): boolean {
52 const kind = thread.pendingRuntimeRequest?.kind;
53 return (
54 kind !== undefined &&
55 kind !== "auth_refresh" &&
56 (kind !== "user_input" || thread.pendingRuntimeRequest?.userOnly === true) &&
57 thread.lineage.relationshipToParent === "subagent" &&
58 !isProviderNativeSubagentThread(thread) &&
59 // The activity run stays set when a follow-up is queued behind a blocked run.
60 thread.activityRunStatus != null
61 );
62}
63
64export interface AgentAwarenessState {
65 readonly environmentId: EnvironmentId;
66 readonly threadId: ThreadId;
67 readonly projectTitle: string;
68 readonly threadTitle: string;
69 readonly phase: AgentAwarenessPhase;
70 readonly headline: string;
71 readonly detail?: string;
72 readonly modelTitle: string;
73 readonly updatedAt: string;
74 readonly deepLink: string;
75}
76
77function buildAgentAwarenessDeepLink(input: {
78 readonly environmentId: EnvironmentId;
79 readonly threadId: ThreadId;
80}): string {
81 return `/threads/${encodeURIComponent(input.environmentId)}/${encodeURIComponent(input.threadId)}`;
82}
83
84export interface ProjectThreadAwarenessV2Input {
85 readonly environmentId: EnvironmentId;
86 readonly project: Pick<Project, "title">;
87 readonly thread: Pick<
88 OrchestrationV2ThreadShell,
89 | "activityRunStatus"
90 | "id"
91 | "lineage"
92 | "latestRunBackgroundWake"
93 | "latestRunStopRequested"
94 | "modelSelection"
95 | "pendingBackgroundTasks"
96 | "pendingRuntimeRequest"
97 | "settledOverride"
98 | "status"
99 | "title"
100 | "updatedAt"
101 >;
102 /** What a live subagent below this thread is waiting on the user for. */
103 readonly pendingSubagentRequest?: SubagentRequestAwareness | null;
104}
105
106export function projectThreadAwarenessV2(
107 input: ProjectThreadAwarenessV2Input,
108): AgentAwarenessState | null {
109 const { environmentId, project, thread } = input;
110 if (!isListedThreadLineage(thread.lineage)) return null;
111 const subagentRequest = input.pendingSubagentRequest ?? null;
112 const phase = resolveThreadAwarenessPhaseV2(thread, subagentRequest);
113 if (phase === null) {
114 return null;
115 }
116 if (phase === "completed" && thread.latestRunStopRequested === true) return null;
117 // Resolve the phase first so live or waiting work stays visible after settlement.
118 const runStatus = thread.activityRunStatus ?? thread.status;
119 if (
120 thread.settledOverride === "settled" &&
121 (phase === "completed" ||
122 phase === "failed" ||
123 (phase === "running" && runStatus === "completed")) &&
124 !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
125 ) {
126 return null;
127 }
128 const detail =
129 phase === "completed"
130 ? "Review the completed task."
131 : phase === "failed"
132 ? "The agent run failed."
133 : undefined;
134 return {
135 environmentId,
136 threadId: thread.id,
137 projectTitle: project.title,
138 threadTitle: thread.title,
139 phase,
140 headline: headlineForPhase(phase),
141 ...(detail === undefined ? {} : { detail }),
142 modelTitle: thread.modelSelection.model,
143 // A child's request leaves the parent's own timestamp alone. Alerts are
144 // keyed by phase and time, so a second wait needs the child's time.
145 updatedAt: DateTime.formatIso(
146 subagentRequest === null
147 ? thread.updatedAt
148 : DateTime.max(thread.updatedAt, subagentRequest.updatedAt),
149 ),
150 deepLink: buildAgentAwarenessDeepLink({ environmentId, threadId: thread.id }),
151 };
152}
153
154function resolveThreadAwarenessPhaseV2(
155 thread: ProjectThreadAwarenessV2Input["thread"],
156 subagentRequest: SubagentRequestAwareness | null,
157): AgentAwarenessPhase | null {
158 if (thread.pendingRuntimeRequest?.kind === "user_input") return "waiting_for_input";
159 if (
160 thread.pendingRuntimeRequest !== null &&
161 thread.pendingRuntimeRequest.kind !== "auth_refresh"
162 ) {
163 return "waiting_for_approval";
164 }
165 // The parent can finish its own turn while a delegated child keeps working,
166 // so a child's request outranks the parent's run status.
167 if (subagentRequest !== null) {
168 return subagentRequest.waitsForInput === true ? "waiting_for_input" : "waiting_for_approval";
169 }
170 switch (thread.activityRunStatus ?? thread.status) {
171 case "preparing":
172 case "starting":
173 return "starting";
174 case "running":
175 case "waiting":
176 return "running";
177 case "completed":
178 return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) ||
179 monitorHoldsWakeCompletion(thread)
180 ? "running"
181 : "completed";
182 case "failed":
183 return "failed";
184 case "idle":
185 case "queued":
186 case "interrupted":
187 case "cancelled":
188 case "rolled_back":
189 return null;
190 }
191}
192
193function headlineForPhase(phase: AgentAwarenessPhase): string {
194 switch (phase) {
195 case "starting":
196 return "Starting agent";
197 case "running":
198 return "Agent is working";
199 case "waiting_for_approval":
200 return "Approval needed";
201 case "waiting_for_input":
202 return "Waiting for input";
203 case "completed":
204 return "Agent finished";
205 case "failed":
206 return "Agent failed";
207 case "stale":
208 return "Update delayed";
209 }
210}