Skip to content
Source
Sign inSign up
Open History to see the last commit

models.ts

410 lines15.7 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 /** Settled user and assistant messages; only archived snapshots carry it. */
180 readonly messageCount?: number;
181 readonly createdAt: string;
182 readonly updatedAt: string;
183 readonly archivedAt: string | null;
184 readonly settledOverride: "settled" | "active" | null;
185 readonly settledAt: string | null;
186 readonly unsettledAt: string | null;
187 readonly snoozedUntil: string | null;
188 readonly snoozedAt: string | null;
189 readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null;
190 readonly pinnedAt: string | null;
191 readonly autoSettleDisabledAt?: string | null;
192 readonly pinOrderKey: string | null;
193 readonly activeOrderKey: string | null;
194 readonly pullRequests: ReadonlyArray<import("@t3tools/contracts").ThreadPullRequestLink>;
195 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;
196 readonly branchPullRequest?: ThreadLinkedPullRequest | null;
197 /**
198 * Server-tracked visited watermark. `undefined` means the environment's
199 * server predates visited tracking and clients should fall back to any
200 * local visited state they keep.
201 */
202 readonly lastVisitedAt?: string | null;
203 /** Server-tracked open history for search ranking. */
204 readonly opens?: import("@t3tools/contracts").OrchestrationV2ThreadOpens | null;
205 readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null;
206 readonly deletedAt: string | null;
207 readonly source: OrchestrationV2ThreadShell;
209​
210function iso(value: DateTime.Utc): string {
211 return DateTime.formatIso(value);
213​
214function nullableIso(value: DateTime.Utc | null): string | null {
215 return value === null ? null : iso(value);
217​
218function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
219 return (
220 status === "completed" ||
221 status === "interrupted" ||
222 status === "failed" ||
223 status === "cancelled" ||
224 status === "rolled_back"
225 );
227​
228// Only work the agent waits on holds it: commands and monitors it left running
229// unmarked, such as a dev server, present the run's own status. The
230// server suppresses the roster while an interruptible activity run exists, so a
231// remaining roster is stronger than checkpoint-oriented waiting.
232function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {
233 const parkAtIdle =
234 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) &&
235 thread.status !== "failed";
236 if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) {
237 return null;
238 }
239 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);
240 return {
241 status,
242 activeRunId: thread.activeRunId,
243 activityStartedAt:
244 thread.activityRunStartedAt === undefined
245 ? undefined
246 : nullableIso(thread.activityRunStartedAt),
247 providerInstanceId: thread.providerInstanceId,
248 providerName: null,
249 lastError: thread.lastError ?? null,
250 lastErrorClass: thread.lastErrorClass ?? null,
251 lastErrorCode: thread.lastErrorCode ?? null,
252 usageLimitResetAt: thread.usageLimitResetAt ?? null,
253 updatedAt: iso(thread.updatedAt),
254 };
256​
257export function scopeProject(
258 environmentId: EnvironmentId,
259 project: OrchestrationProjectShell,
260): EnvironmentProject {
261 return { ...project, environmentId };
263​
264export type PendingSubagentRequest = "approval" | "user_input";
265​
266export function presentThreadShell(
267 environmentId: EnvironmentId,
268 thread: OrchestrationV2ThreadShell,
269 pendingSubagentRequest: PendingSubagentRequest | null = null,
270): EnvironmentThreadShell {
271 const updatedAt = iso(thread.updatedAt);
272 const latestRun =
273 thread.latestRunId === null
274 ? null
275 : ({
276 runId: thread.latestRunId,
277 status: thread.status === "idle" ? "completed" : thread.status,
278 requestedAt: nullableIso(thread.latestRunRequestedAt ?? null),
279 startedAt: nullableIso(thread.latestRunStartedAt ?? null),
280 completedAt:
281 thread.latestRunCompletedAt === undefined
282 ? thread.status === "idle" || terminalRunStatus(thread.status)
283 ? updatedAt
284 : null
285 : nullableIso(thread.latestRunCompletedAt),
286 assistantMessageId: null,
287 } satisfies ThreadRunSummary);
288 const runtime = shellRuntime(thread);
289 return {
290 environmentId,
291 id: thread.id,
292 projectId: thread.projectId,
293 title:
294 thread.lineage.relationshipToParent === "subagent"
295 ? formatSubagentDisplayTitle(thread.title)
296 : thread.title,
297 providerInstanceId: thread.providerInstanceId,
298 modelSelection: thread.modelSelection,
299 runtimeMode: thread.runtimeMode,
300 interactionMode: thread.interactionMode,
301 branch: thread.branch,
302 worktreePath: thread.worktreePath,
303 pullRequests: threadPullRequestsOf(thread),
304 linkedPullRequest: thread.linkedPullRequest ?? null,
305 branchPullRequest: thread.branchPullRequest ?? null,
306 lineage: thread.lineage,
307 forkedFrom: thread.forkedFrom,
308 linkOrigin: thread.linkOrigin ?? null,
309 delegatedFrom: thread.delegatedFrom ?? null,
310 activeProviderThreadId: thread.activeProviderThreadId,
311 latestRun,
312 runtime,
313 blockedOnThreadWait: thread.blockedOnThreadWait ?? false,
314 latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
315 ...(thread.latestUserAuthoredMessageAt === undefined
316 ? {}
317 : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }),
318 hasPendingApprovals:
319 thread.pendingRuntimeRequest !== null &&
320 thread.pendingRuntimeRequest.kind !== "user_input" &&
321 thread.pendingRuntimeRequest.kind !== "auth_refresh",
322 // Async questions count too, so a thread with any open question leaves Working.
323 hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input",
324 pendingUserInputAt:
325 thread.pendingRuntimeRequest?.kind === "user_input"
326 ? iso(thread.pendingRuntimeRequest.createdAt)
327 : null,
328 pendingSubagentRequest,
329 pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0,
330 hasActionableProposedPlan: thread.hasActionableProposedPlan,
331 pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [],
332 providerInstanceHistory: thread.providerInstanceHistory ?? [],
333 goal: thread.goal ?? null,
334 itemCount: thread.itemCount,
335 visibleItemCount: thread.visibleItemCount,
336 ...(thread.messageCount === undefined ? {} : { messageCount: thread.messageCount }),
337 createdAt: iso(thread.createdAt),
338 updatedAt,
339 archivedAt: nullableIso(thread.archivedAt),
340 settledOverride: thread.settledOverride,
341 settledAt: nullableIso(thread.settledAt),
342 unsettledAt: nullableIso(thread.unsettledAt ?? null),
343 snoozedUntil: nullableIso(thread.snoozedUntil ?? null),
344 snoozedAt: nullableIso(thread.snoozedAt ?? null),
345 limitRecovery: thread.limitRecovery ?? null,
346 pinnedAt: nullableIso(thread.pinnedAt ?? null),
347 autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null),
348 pinOrderKey: thread.pinOrderKey ?? null,
349 activeOrderKey: thread.activeOrderKey ?? null,
350 ...(thread.lastVisitedAt === undefined
351 ? {}
352 : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }),
353 opens: thread.opens ?? null,
354 titleRegeneration:
355 thread.titleRegeneration == null
356 ? null
357 : {
358 requestId: thread.titleRegeneration.requestId,
359 startedAt: iso(thread.titleRegeneration.startedAt),
360 },
361 deletedAt: nullableIso(thread.deletedAt),
362 source: thread,
363 };
365​
366export function scopeThreadShell(
367 environmentId: EnvironmentId,
368 thread: OrchestrationV2ThreadShell,
369): EnvironmentThreadShell {
370 return presentThreadShell(environmentId, thread);
372​
373const THREAD_PROVIDER_STACK_LIMIT = 3;
374​
375export function resolveThreadProviderStack(
376 thread: Pick<EnvironmentThreadShell, "providerInstanceHistory" | "modelSelection" | "runtime">,
377): ReadonlyArray<ProviderInstanceId> {
378 const current = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId;
379 const previous = thread.providerInstanceHistory.filter((instanceId) => instanceId !== current);
380 return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current];
382​
383/**
384 * Both shell and detail timers count from the activity-owning run's work
385 * start, never last activity. A wake keeps the start of the work it continues.
386 */
387export function resolveThreadWorkingStartedAt(input: {
388 readonly latestRun: Pick<
389 ThreadRunSummary,
390 "runId" | "startedAt" | "requestedAt" | "completedAt"
391 > | null;
392 readonly runtime: Pick<ThreadRuntimeSummary, "activeRunId" | "activityStartedAt"> | null;
393}): string | null {
394 const valid = (value: string | null | undefined) =>
395 value != null && Number.isFinite(Date.parse(value)) ? value : null;
396 if (input.runtime?.activityStartedAt !== undefined) return valid(input.runtime.activityStartedAt);
397 const run = input.latestRun;
398 if (run?.completedAt === null && run.runId === input.runtime?.activeRunId) {
399 return valid(run.startedAt) ?? valid(run.requestedAt);
400 }
401 return null;
403​
404export function formatWorkingDurationLabel(elapsedMs: number): string {
405 const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0;
406 if (seconds < 60) return `${seconds}s`;
407 const minutes = Math.floor(seconds / 60);
408 if (minutes < 60) return `${minutes}m`;
409 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;