Skip to content
Source
Sign inSign up

models.ts

407 lines15.5 KB

1import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
2import { threadPullRequestsOf } from "../threadPullRequests.ts";
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 readonly isScratch?: true;
27​
28/**
29 * The projection stays nested so the server projection and all structurally
30 * shared collections retain their identities.
31 */
32export interface EnvironmentThread {
33 readonly environmentId: EnvironmentId;
34 readonly projection: OrchestrationV2ThreadProjection;
36​
37type ProjectionTurnItem = OrchestrationV2ThreadProjection["turnItems"][number];
38type StreamedProjectionTurnItem = Extract<
39 ProjectionTurnItem,
40 { readonly type: "assistant_message" | "reasoning" }
41>;
42​
43/** A turn item without the fields that move while it streams. */
44export type ThreadStructureTurnItem =
45 | Exclude<ProjectionTurnItem, StreamedProjectionTurnItem>
46 | Omit<Extract<ProjectionTurnItem, { readonly type: "assistant_message" }>, "text" | "updatedAt">
47 | Omit<Extract<ProjectionTurnItem, { readonly type: "reasoning" }>, "text" | "updatedAt">;
48​
49export type ThreadStructureVisibleTurnItem = Omit<
50 OrchestrationV2ThreadProjection["visibleTurnItems"][number],
51 "item"
52> & {
53 readonly item: ThreadStructureTurnItem;
54};
55​
56/**
57 * A thread projection without the fields that move while a reply streams, so a
58 * reader of the quiet thread cannot depend on them and go stale. A full
59 * projection is assignable to it.
60 */
61export type ThreadStructureThread = Omit<
62 OrchestrationV2ThreadProjection["thread"],
63 "updatedAt" | "lastVisitedAt"
64>;
65​
66export type ThreadStructure = Omit<
67 OrchestrationV2ThreadProjection,
68 "thread" | "turnItems" | "visibleTurnItems" | "updatedAt"
69> & {
70 readonly thread: ThreadStructureThread;
71 readonly turnItems: ReadonlyArray<ThreadStructureTurnItem>;
72 readonly visibleTurnItems: ReadonlyArray<ThreadStructureVisibleTurnItem>;
73};
74​
75export interface EnvironmentThreadStructure {
76 readonly environmentId: EnvironmentId;
77 readonly projection: ThreadStructure;
79​
80export interface ThreadRunSummary {
81 readonly runId: RunId;
82 readonly status: OrchestrationV2RunStatus;
83 readonly requestedAt: string | null;
84 readonly startedAt: string | null;
85 readonly completedAt: string | null;
86 readonly assistantMessageId: MessageId | null;
87 /**
88 * The run is still open, but its provider turn already ended, so nothing is
89 * generating while the server settles it.
90 */
91 readonly turnEnded?: boolean;
92 /**
93 * Set while the run is open but its provider has not started a turn, so the
94 * provider is still launching and nothing is generating. Clients show it
95 * instead of "Thinking".
96 */
97 readonly providerStartingLabel?: string;
98 readonly sourcePlanRef?: {
99 readonly threadId: ThreadId;
100 readonly planId: PlanId;
101 };
103​
104export interface ThreadRuntimeSummary {
105 readonly status: OrchestrationV2RunStatus | "idle";
106 readonly activeRunId: RunId | null;
107 readonly activityStartedAt?: string | null | undefined;
108 readonly providerInstanceId: ProviderInstanceId;
109 readonly providerName: string | null;
110 readonly lastError: string | null;
111 readonly lastErrorClass?: OrchestrationV2ProviderFailureClass | null;
112 readonly lastErrorCode?: string | null;
113 readonly usageLimitResetAt?: string | null;
114 readonly updatedAt: string;
116​
117/** The runtime summary a thread's structure determines; see ThreadStructure. */
118export type ThreadStructureRuntime = Omit<ThreadRuntimeSummary, "updatedAt">;
119​
120export function threadRuntimeIsActive(runtime: ThreadStructureRuntime | null | undefined): boolean {
121 return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status);
123​
124export function threadRuntimeCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean {
125 if (runtime?.status === "queued") return runtime.activeRunId === null;
126 return (
127 runtime?.status !== "preparing" &&
128 runtime?.status !== "starting" &&
129 runtime?.status !== "running"
130 );
132​
133function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean {
134 return (
135 status === "preparing" ||
136 status === "queued" ||
137 status === "starting" ||
138 status === "running" ||
139 status === "waiting"
140 );
142​
143export interface EnvironmentThreadShell {
144 readonly environmentId: EnvironmentId;
145 readonly id: ThreadId;
146 readonly projectId: ProjectId;
147 readonly title: string;
148 readonly providerInstanceId: ProviderInstanceId;
149 readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"];
150 readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"];
151 readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"];
152 readonly branch: string | null;
153 readonly worktreePath: string | null;
154 readonly lineage: OrchestrationV2ThreadShell["lineage"];
155 readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"];
156 readonly linkOrigin: OrchestrationV2ThreadShell["linkOrigin"] | null;
157 /** The thread in a linked environment that delegated this one, if one did. */
158 readonly delegatedFrom: OrchestrationV2ThreadShell["delegatedFrom"] | null;
159 readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"];
160 readonly latestRun: ThreadRunSummary | null;
161 readonly runtime: ThreadRuntimeSummary | null;
162 readonly blockedOnThreadWait?: boolean;
163 readonly latestUserMessageAt: string | null;
164 readonly latestUserAuthoredMessageAt?: string | null;
165 readonly hasPendingApprovals: boolean;
166 readonly hasPendingUserInput: boolean;
167 readonly pendingUserInputAt?: string | null;
168 readonly pendingSubagentRequest?: PendingSubagentRequest | null;
169 /** Guest messages waiting for the owner to send or dismiss. */
170 readonly pendingGuestMessageCount?: number;
171 readonly hasActionableProposedPlan: boolean;
172 readonly pendingBackgroundTasks: ReadonlyArray<
173 NonNullable<OrchestrationV2ThreadShell["pendingBackgroundTasks"]>[number]
174 >;
175 readonly providerInstanceHistory: ReadonlyArray<ProviderInstanceId>;
176 readonly goal: OrchestrationV2ProviderGoal | null;
177 readonly itemCount: number;
178 readonly visibleItemCount: number;
179 readonly createdAt: string;
180 readonly updatedAt: string;
181 readonly archivedAt: string | null;
182 readonly settledOverride: "settled" | "active" | null;
183 readonly settledAt: string | null;
184 readonly unsettledAt: string | null;
185 readonly snoozedUntil: string | null;
186 readonly snoozedAt: string | null;
187 readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null;
188 readonly pinnedAt: string | null;
189 readonly autoSettleDisabledAt?: string | null;
190 readonly pinOrderKey: string | null;
191 readonly activeOrderKey: string | null;
192 readonly pullRequests: ReadonlyArray<import("@t3tools/contracts").ThreadPullRequestLink>;
193 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;
194 readonly branchPullRequest?: ThreadLinkedPullRequest | null;
195 /**
196 * Server-tracked visited watermark. `undefined` means the environment's
197 * server predates visited tracking and clients should fall back to any
198 * local visited state they keep.
199 */
200 readonly lastVisitedAt?: string | null;
201 /** Server-tracked open history for search ranking. */
202 readonly opens?: import("@t3tools/contracts").OrchestrationV2ThreadOpens | null;
203 readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null;
204 readonly deletedAt: string | null;
205 readonly source: OrchestrationV2ThreadShell;
207​
208function iso(value: DateTime.Utc): string {
209 return DateTime.formatIso(value);
211​
212function nullableIso(value: DateTime.Utc | null): string | null {
213 return value === null ? null : iso(value);
215​
216function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
217 return (
218 status === "completed" ||
219 status === "interrupted" ||
220 status === "failed" ||
221 status === "cancelled" ||
222 status === "rolled_back"
223 );
225​
226// Only work the agent waits on holds it: commands and monitors it left running
227// unmarked, such as a dev server, present the run's own status. The
228// server suppresses the roster while an interruptible activity run exists, so a
229// remaining roster is stronger than checkpoint-oriented waiting.
230function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {
231 const parkAtIdle =
232 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) &&
233 thread.status !== "failed";
234 if (thread.latestRunId === null && !parkAtIdle) {
235 return null;
236 }
237 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);
238 return {
239 status,
240 activeRunId: thread.activeRunId,
241 activityStartedAt:
242 thread.activityRunStartedAt === undefined
243 ? undefined
244 : nullableIso(thread.activityRunStartedAt),
245 providerInstanceId: thread.providerInstanceId,
246 providerName: null,
247 lastError: thread.lastError ?? null,
248 lastErrorClass: thread.lastErrorClass ?? null,
249 lastErrorCode: thread.lastErrorCode ?? null,
250 usageLimitResetAt: thread.usageLimitResetAt ?? null,
251 updatedAt: iso(thread.updatedAt),
252 };
254​
255export function scopeProject(
256 environmentId: EnvironmentId,
257 project: OrchestrationProjectShell,
258): EnvironmentProject {
259 return { ...project, environmentId };
261​
262export type PendingSubagentRequest = "approval" | "user_input";
263​
264export function presentThreadShell(
265 environmentId: EnvironmentId,
266 thread: OrchestrationV2ThreadShell,
267 pendingSubagentRequest: PendingSubagentRequest | null = null,
268): EnvironmentThreadShell {
269 const updatedAt = iso(thread.updatedAt);
270 const latestRun =
271 thread.latestRunId === null
272 ? null
273 : ({
274 runId: thread.latestRunId,
275 status: thread.status === "idle" ? "completed" : thread.status,
276 requestedAt: nullableIso(thread.latestRunRequestedAt ?? null),
277 startedAt: nullableIso(thread.latestRunStartedAt ?? null),
278 completedAt:
279 thread.latestRunCompletedAt === undefined
280 ? thread.status === "idle" || terminalRunStatus(thread.status)
281 ? updatedAt
282 : null
283 : nullableIso(thread.latestRunCompletedAt),
284 assistantMessageId: null,
285 } satisfies ThreadRunSummary);
286 const runtime = shellRuntime(thread);
287 return {
288 environmentId,
289 id: thread.id,
290 projectId: thread.projectId,
291 title:
292 thread.lineage.relationshipToParent === "subagent"
293 ? formatSubagentDisplayTitle(thread.title)
294 : thread.title,
295 providerInstanceId: thread.providerInstanceId,
296 modelSelection: thread.modelSelection,
297 runtimeMode: thread.runtimeMode,
298 interactionMode: thread.interactionMode,
299 branch: thread.branch,
300 worktreePath: thread.worktreePath,
301 pullRequests: threadPullRequestsOf(thread),
302 linkedPullRequest: thread.linkedPullRequest ?? null,
303 branchPullRequest: thread.branchPullRequest ?? null,
304 lineage: thread.lineage,
305 forkedFrom: thread.forkedFrom,
306 linkOrigin: thread.linkOrigin ?? null,
307 delegatedFrom: thread.delegatedFrom ?? null,
308 activeProviderThreadId: thread.activeProviderThreadId,
309 latestRun,
310 runtime,
311 blockedOnThreadWait: thread.blockedOnThreadWait ?? false,
312 latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
313 ...(thread.latestUserAuthoredMessageAt === undefined
314 ? {}
315 : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }),
316 hasPendingApprovals:
317 thread.pendingRuntimeRequest !== null &&
318 thread.pendingRuntimeRequest.kind !== "user_input" &&
319 thread.pendingRuntimeRequest.kind !== "auth_refresh",
320 // Async questions count too, so a thread with any open question leaves Working.
321 hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
322 pendingUserInputAt:
323 thread.pendingRuntimeRequest?.kind === "user_input"
324 ? iso(thread.pendingRuntimeRequest.createdAt)
325 : null,
326 pendingSubagentRequest,
327 pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0,
328 hasActionableProposedPlan: thread.hasActionableProposedPlan,
329 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
330 providerInstanceHistory: thread.providerInstanceHistory ?? [],
331 goal: thread.goal ?? null,
332 itemCount: thread.itemCount,
333 visibleItemCount: thread.visibleItemCount,
334 createdAt: iso(thread.createdAt),
335 updatedAt,
336 archivedAt: nullableIso(thread.archivedAt),
337 settledOverride: thread.settledOverride,
338 settledAt: nullableIso(thread.settledAt),
339 unsettledAt: nullableIso(thread.unsettledAt ?? null),
340 snoozedUntil: nullableIso(thread.snoozedUntil ?? null),
341 snoozedAt: nullableIso(thread.snoozedAt ?? null),
342 limitRecovery: thread.limitRecovery ?? null,
343 pinnedAt: nullableIso(thread.pinnedAt ?? null),
344 autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null),
345 pinOrderKey: thread.pinOrderKey ?? null,
346 activeOrderKey: thread.activeOrderKey ?? null,
347 ...(thread.lastVisitedAt === undefined
348 ? {}
349 : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }),
350 opens: thread.opens ?? null,
351 titleRegeneration:
352 thread.titleRegeneration == null
353 ? null
354 : {
355 requestId: thread.titleRegeneration.requestId,
356 startedAt: iso(thread.titleRegeneration.startedAt),
357 },
358 deletedAt: nullableIso(thread.deletedAt),
359 source: thread,
360 };
362​
363export function scopeThreadShell(
364 environmentId: EnvironmentId,
365 thread: OrchestrationV2ThreadShell,
366): EnvironmentThreadShell {
367 return presentThreadShell(environmentId, thread);
369​
370const THREAD_PROVIDER_STACK_LIMIT = 3;
371​
372export function resolveThreadProviderStack(
373 thread: Pick<EnvironmentThreadShell, "providerInstanceHistory" | "modelSelection" | "runtime">,
374): ReadonlyArray<ProviderInstanceId> {
375 const current = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
376 const previous = thread.providerInstanceHistory.filter((instanceId) => instanceId !== current);
377 return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current];
379​
380/**
381 * Both shell and detail timers count from the activity-owning run's work
382 * start, never last activity. A wake keeps the start of the work it continues.
383 */
384export function resolveThreadWorkingStartedAt(input: {
385 readonly latestRun: Pick<
386 ThreadRunSummary,
387 "runId" | "startedAt" | "requestedAt" | "completedAt"
388 > | null;
389 readonly runtime: Pick<ThreadRuntimeSummary, "activeRunId" | "activityStartedAt"> | null;
390}): string | null {
391 const valid = (value: string | null | undefined) =>
392 value != null && Number.isFinite(Date.parse(value)) ? value : null;
393 if (input.runtime?.activityStartedAt !== undefined) return valid(input.runtime.activityStartedAt);
394 const run = input.latestRun;
395 if (run?.completedAt === null && run.runId === input.runtime?.activeRunId) {
396 return valid(run.startedAt) ?? valid(run.requestedAt);
397 }
398 return null;
400​
401export function formatWorkingDurationLabel(elapsedMs: number): string {
402 const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0;
403 if (seconds < 60) return `${seconds}s`;
404 const minutes = Math.floor(seconds / 60);
405 if (minutes < 60) return `${minutes}m`;
406 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;