1import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
2import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";
3import type {
4 ThreadLinkedPullRequest,
5 EnvironmentId,
6 MessageId,
7 OrchestrationProjectShell,
8 OrchestrationV2ProviderGoal,
9 OrchestrationV2RunStatus,
10 OrchestrationV2ProviderFailureClass,
11 OrchestrationV2ThreadProjection,
12 OrchestrationV2ThreadShell,
13 PlanId,
14 ProjectId,
15 ProviderInstanceId,
16 RunId,
17 ThreadId,
18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";
20
21import { formatSubagentDisplayTitle } from "./subagentDisplay.ts";
22
23export interface EnvironmentProject extends OrchestrationProjectShell {
24 readonly environmentId: EnvironmentId;
25 /** Derived from the environment's configured scratch root, never persisted. */
26 readonly isScratch?: true;
27}
28
29/**
30 * A pristine V2 thread projection paired with the environment that produced it.
31 *
32 * The projection stays nested so the server projection and all structurally
33 * shared collections retain their identities. Rich consumers read V2 state
34 * directly instead of a second presentation-shaped thread graph.
35 */
36export interface EnvironmentThread {
37 readonly environmentId: EnvironmentId;
38 readonly projection: OrchestrationV2ThreadProjection;
39}
40
41export interface ThreadRunSummary {
42 readonly runId: RunId;
43 readonly status: OrchestrationV2RunStatus;
44 readonly requestedAt: string | null;
45 readonly startedAt: string | null;
46 readonly completedAt: string | null;
47 readonly assistantMessageId: MessageId | null;
48 readonly sourcePlanRef?: {
49 readonly threadId: ThreadId;
50 readonly planId: PlanId;
51 };
52}
53
54export interface ThreadRuntimeSummary {
55 readonly status: OrchestrationV2RunStatus | "idle";
56 readonly activeRunId: RunId | null;
57 readonly activityStartedAt?: string | null | undefined;
58 readonly providerInstanceId: ProviderInstanceId;
59 readonly providerName: string | null;
60 readonly lastError: string | null;
61 readonly lastErrorClass?: OrchestrationV2ProviderFailureClass | null;
62 readonly usageLimitResetAt?: string | null;
63 readonly updatedAt: string;
64}
65
66export function threadRuntimeIsActive(runtime: ThreadRuntimeSummary | null | undefined): boolean {
67 return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status);
68}
69
70/** Archiving may discard queued work, but it must not detach a provider that
71 * is preparing, starting, or running a turn. */
72export function threadRuntimeCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean {
73 if (runtime?.status === "queued") return runtime.activeRunId === null;
74 return (
75 runtime?.status !== "preparing" &&
76 runtime?.status !== "starting" &&
77 runtime?.status !== "running"
78 );
79}
80
81function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean {
82 return (
83 status === "preparing" ||
84 status === "queued" ||
85 status === "starting" ||
86 status === "running" ||
87 status === "waiting"
88 );
89}
90
91export interface EnvironmentThreadShell {
92 readonly environmentId: EnvironmentId;
93 readonly id: ThreadId;
94 readonly projectId: ProjectId;
95 readonly title: string;
96 readonly providerInstanceId: ProviderInstanceId;
97 readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"];
98 readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"];
99 readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"];
100 readonly branch: string | null;
101 readonly worktreePath: string | null;
102 readonly lineage: OrchestrationV2ThreadShell["lineage"];
103 readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"];
104 readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"];
105 readonly latestRun: ThreadRunSummary | null;
106 readonly runtime: ThreadRuntimeSummary | null;
107 readonly latestUserMessageAt: string | null;
108 /** The last message the user wrote. `undefined` means the server predates it. */
109 readonly latestUserAuthoredMessageAt?: string | null;
110 readonly hasPendingApprovals: boolean;
111 readonly hasPendingUserInput: boolean;
112 /** What a live subagent below this thread waits on the user for. Subagent
113 rows are hidden, so the threads above them carry it. */
114 readonly pendingSubagentRequest?: PendingSubagentRequest | null;
115 readonly hasActionableProposedPlan: boolean;
116 readonly pendingBackgroundTasks: ReadonlyArray<
117 NonNullable<OrchestrationV2ThreadShell["pendingBackgroundTasks"]>[number]
118 >;
119 /** Provider instances that have owned the root conversation, oldest first. */
120 readonly providerInstanceHistory: ReadonlyArray<ProviderInstanceId>;
121 /** Native `/goal` on the active provider thread. */
122 readonly goal: OrchestrationV2ProviderGoal | null;
123 readonly itemCount: number;
124 readonly visibleItemCount: number;
125 readonly createdAt: string;
126 readonly updatedAt: string;
127 readonly archivedAt: string | null;
128 readonly settledOverride: "settled" | "active" | null;
129 readonly settledAt: string | null;
130 readonly unsettledAt: string | null;
131 readonly snoozedUntil: string | null;
132 readonly snoozedAt: string | null;
133 readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null;
134 readonly pinnedAt: string | null;
135 readonly autoSettleDisabledAt?: string | null;
136 /** Slot in the user-arranged pinned order; null for keyless (legacy) pins. */
137 readonly pinOrderKey: string | null;
138 /** Slot in the user-arranged active order; null for keyless active threads. */
139 readonly activeOrderKey: string | null;
140 readonly pullRequests: ReadonlyArray<import("@t3tools/contracts").ThreadPullRequestLink>;
141 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;
142 readonly branchPullRequest?: ThreadLinkedPullRequest | null;
143 /**
144 * Server-tracked visited watermark. `undefined` means the environment's
145 * server predates visited tracking and clients should fall back to any
146 * local visited state they keep.
147 */
148 readonly lastVisitedAt?: string | null;
149 /** Pending title regeneration marker; null when no request is in flight. */
150 readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null;
151 readonly deletedAt: string | null;
152 readonly source: OrchestrationV2ThreadShell;
153}
154
155function iso(value: DateTime.Utc): string {
156 return DateTime.formatIso(value);
157}
158
159function nullableIso(value: DateTime.Utc | null): string | null {
160 return value === null ? null : iso(value);
161}
162
163function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
164 return (
165 status === "completed" ||
166 status === "interrupted" ||
167 status === "failed" ||
168 status === "cancelled" ||
169 status === "rolled_back"
170 );
171}
172
173// Park runtime at idle when the post-settlement background roster holds the
174// run's completion, so #4415 waiting-presentation Waiting (session.idle) can
175// consume CTM runtime. Only work that wakes the agent holds it: commands it
176// left running, such as a dev server, present the run's own status (#14872).
177// The server suppresses the roster while an interruptible activity run exists,
178// so a remaining roster is stronger than checkpoint-oriented waiting.
179// latestRun keeps the latest run's status for history presentation.
180// A failed latest run outranks the roster, so the failure stays visible.
181function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {
182 const parkAtIdle =
183 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) &&
184 thread.status !== "failed";
185 // A pull request watch can hold a thread that never ran.
186 if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) {
187 return null;
188 }
189 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);
190 return {
191 status,
192 activeRunId: thread.activeRunId,
193 activityStartedAt:
194 thread.activityRunStartedAt === undefined
195 ? undefined
196 : nullableIso(thread.activityRunStartedAt),
197 providerInstanceId: thread.providerInstanceId,
198 providerName: null,
199 lastError: thread.lastError ?? null,
200 lastErrorClass: thread.lastErrorClass ?? null,
201 usageLimitResetAt: thread.usageLimitResetAt ?? null,
202 updatedAt: iso(thread.updatedAt),
203 };
204}
205
206export function scopeProject(
207 environmentId: EnvironmentId,
208 project: OrchestrationProjectShell,
209): EnvironmentProject {
210 return { ...project, environmentId };
211}
212
213export type PendingSubagentRequest = "approval" | "user_input";
214
215export function presentThreadShell(
216 environmentId: EnvironmentId,
217 thread: OrchestrationV2ThreadShell,
218 pendingSubagentRequest: PendingSubagentRequest | null = null,
219): EnvironmentThreadShell {
220 const updatedAt = iso(thread.updatedAt);
221 const latestRun =
222 thread.latestRunId === null
223 ? null
224 : ({
225 runId: thread.latestRunId,
226 status: thread.status === "idle" ? "completed" : thread.status,
227 requestedAt: nullableIso(thread.latestRunRequestedAt ?? null),
228 startedAt: nullableIso(thread.latestRunStartedAt ?? null),
229 completedAt:
230 thread.latestRunCompletedAt === undefined
231 ? thread.status === "idle" || terminalRunStatus(thread.status)
232 ? updatedAt
233 : null
234 : nullableIso(thread.latestRunCompletedAt),
235 assistantMessageId: null,
236 } satisfies ThreadRunSummary);
237 return {
238 environmentId,
239 id: thread.id,
240 projectId: thread.projectId,
241 title:
242 thread.lineage.relationshipToParent === "subagent"
243 ? formatSubagentDisplayTitle(thread.title)
244 : thread.title,
245 providerInstanceId: thread.providerInstanceId,
246 modelSelection: thread.modelSelection,
247 runtimeMode: thread.runtimeMode,
248 interactionMode: thread.interactionMode,
249 branch: thread.branch,
250 worktreePath: thread.worktreePath,
251 pullRequests: threadPullRequestsOf(thread),
252 linkedPullRequest: thread.linkedPullRequest ?? null,
253 branchPullRequest: thread.branchPullRequest ?? null,
254 lineage: thread.lineage,
255 forkedFrom: thread.forkedFrom,
256 activeProviderThreadId: thread.activeProviderThreadId,
257 latestRun,
258 runtime: shellRuntime(thread),
259 latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
260 ...(thread.latestUserAuthoredMessageAt === undefined
261 ? {}
262 : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }),
263 hasPendingApprovals:
264 thread.pendingRuntimeRequest !== null &&
265 thread.pendingRuntimeRequest.kind !== "user_input" &&
266 thread.pendingRuntimeRequest.kind !== "auth_refresh",
267 hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
268 pendingSubagentRequest,
269 hasActionableProposedPlan: thread.hasActionableProposedPlan,
270 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
271 providerInstanceHistory: thread.providerInstanceHistory ?? [],
272 goal: thread.goal ?? null,
273 itemCount: thread.itemCount,
274 visibleItemCount: thread.visibleItemCount,
275 createdAt: iso(thread.createdAt),
276 updatedAt,
277 archivedAt: nullableIso(thread.archivedAt),
278 settledOverride: thread.settledOverride,
279 settledAt: nullableIso(thread.settledAt),
280 unsettledAt: nullableIso(thread.unsettledAt ?? null),
281 snoozedUntil: nullableIso(thread.snoozedUntil ?? null),
282 snoozedAt: nullableIso(thread.snoozedAt ?? null),
283 limitRecovery: thread.limitRecovery ?? null,
284 pinnedAt: nullableIso(thread.pinnedAt ?? null),
285 autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null),
286 pinOrderKey: thread.pinOrderKey ?? null,
287 activeOrderKey: thread.activeOrderKey ?? null,
288 ...(thread.lastVisitedAt === undefined
289 ? {}
290 : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }),
291 titleRegeneration:
292 thread.titleRegeneration == null
293 ? null
294 : {
295 requestId: thread.titleRegeneration.requestId,
296 startedAt: iso(thread.titleRegeneration.startedAt),
297 },
298 deletedAt: nullableIso(thread.deletedAt),
299 source: thread,
300 };
301}
302
303export function scopeThreadShell(
304 environmentId: EnvironmentId,
305 thread: OrchestrationV2ThreadShell,
306): EnvironmentThreadShell {
307 return presentThreadShell(environmentId, thread);
308}
309
310const THREAD_PROVIDER_STACK_LIMIT = 3;
311
312/**
313 * Provider instances to draw in a thread row's trailing stack, back to front:
314 * the current one is always last, earlier owners precede it oldest first.
315 * Newest history wins when the thread has been handed off more times than fit.
316 */
317export function resolveThreadProviderStack(
318 thread: Pick<EnvironmentThreadShell, "providerInstanceHistory" | "modelSelection" | "runtime">,
319): ReadonlyArray<ProviderInstanceId> {
320 const current = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
321 const previous = thread.providerInstanceHistory.filter((instanceId) => instanceId !== current);
322 return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current];
323}
324
325/**
326 * Both shell and detail timers count from the activity-owning run's work
327 * start, never last activity. A wake keeps the start of the work it continues.
328 */
329export function resolveThreadWorkingStartedAt(input: {
330 readonly latestRun: Pick<
331 ThreadRunSummary,
332 "runId" | "startedAt" | "requestedAt" | "completedAt"
333 > | null;
334 readonly runtime: Pick<ThreadRuntimeSummary, "activeRunId" | "activityStartedAt"> | null;
335}): string | null {
336 const valid = (value: string | null | undefined) =>
337 value != null && Number.isFinite(Date.parse(value)) ? value : null;
338 if (input.runtime?.activityStartedAt !== undefined) return valid(input.runtime.activityStartedAt);
339 // Older servers can supply a timestamp only if the newest run owns the work.
340 const run = input.latestRun;
341 if (run?.completedAt === null && run.runId === input.runtime?.activeRunId) {
342 return valid(run.startedAt) ?? valid(run.requestedAt);
343 }
344 return null;
345}