import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { threadPullRequestsOf } from "../threadPullRequests.ts"; import type { ThreadLinkedPullRequest, EnvironmentId, MessageId, OrchestrationProjectShell, OrchestrationV2ProviderGoal, OrchestrationV2RunStatus, OrchestrationV2ProviderFailureClass, OrchestrationV2ThreadProjection, OrchestrationV2ThreadShell, PlanId, ProjectId, ProviderInstanceId, RunId, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { formatSubagentDisplayTitle } from "./subagentDisplay.ts"; export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; readonly isScratch?: true; } /** * The projection stays nested so the server projection and all structurally * shared collections retain their identities. */ export interface EnvironmentThread { readonly environmentId: EnvironmentId; readonly projection: OrchestrationV2ThreadProjection; } type ProjectionTurnItem = OrchestrationV2ThreadProjection["turnItems"][number]; type StreamedProjectionTurnItem = Extract< ProjectionTurnItem, { readonly type: "assistant_message" | "reasoning" } >; /** A turn item without the fields that move while it streams. */ export type ThreadStructureTurnItem = | Exclude | Omit, "text" | "updatedAt"> | Omit, "text" | "updatedAt">; export type ThreadStructureVisibleTurnItem = Omit< OrchestrationV2ThreadProjection["visibleTurnItems"][number], "item" > & { readonly item: ThreadStructureTurnItem; }; /** * A thread projection without the fields that move while a reply streams, so a * reader of the quiet thread cannot depend on them and go stale. A full * projection is assignable to it. */ export type ThreadStructureThread = Omit< OrchestrationV2ThreadProjection["thread"], "updatedAt" | "lastVisitedAt" >; export type ThreadStructure = Omit< OrchestrationV2ThreadProjection, "thread" | "turnItems" | "visibleTurnItems" | "updatedAt" > & { readonly thread: ThreadStructureThread; readonly turnItems: ReadonlyArray; readonly visibleTurnItems: ReadonlyArray; }; export interface EnvironmentThreadStructure { readonly environmentId: EnvironmentId; readonly projection: ThreadStructure; } export interface ThreadRunSummary { readonly runId: RunId; readonly status: OrchestrationV2RunStatus; readonly requestedAt: string | null; readonly startedAt: string | null; readonly completedAt: string | null; readonly assistantMessageId: MessageId | null; /** * The run is still open, but its provider turn already ended, so nothing is * generating while the server settles it. */ readonly turnEnded?: boolean; readonly sourcePlanRef?: { readonly threadId: ThreadId; readonly planId: PlanId; }; } export interface ThreadRuntimeSummary { readonly status: OrchestrationV2RunStatus | "idle"; readonly activeRunId: RunId | null; readonly activityStartedAt?: string | null | undefined; readonly providerInstanceId: ProviderInstanceId; readonly providerName: string | null; readonly lastError: string | null; readonly lastErrorClass?: OrchestrationV2ProviderFailureClass | null; readonly lastErrorCode?: string | null; readonly usageLimitResetAt?: string | null; readonly updatedAt: string; } /** The runtime summary a thread's structure determines; see ThreadStructure. */ export type ThreadStructureRuntime = Omit; export function threadRuntimeIsActive(runtime: ThreadStructureRuntime | null | undefined): boolean { return runtime !== null && runtime !== undefined && threadRunStatusIsActive(runtime.status); } export function threadRuntimeCanArchive(runtime: ThreadRuntimeSummary | null | undefined): boolean { if (runtime?.status === "queued") return runtime.activeRunId === null; return ( runtime?.status !== "preparing" && runtime?.status !== "starting" && runtime?.status !== "running" ); } function threadRunStatusIsActive(status: ThreadRuntimeSummary["status"]): boolean { return ( status === "preparing" || status === "queued" || status === "starting" || status === "running" || status === "waiting" ); } export interface EnvironmentThreadShell { readonly environmentId: EnvironmentId; readonly id: ThreadId; readonly projectId: ProjectId; readonly title: string; readonly providerInstanceId: ProviderInstanceId; readonly modelSelection: OrchestrationV2ThreadShell["modelSelection"]; readonly runtimeMode: OrchestrationV2ThreadShell["runtimeMode"]; readonly interactionMode: OrchestrationV2ThreadShell["interactionMode"]; readonly branch: string | null; readonly worktreePath: string | null; readonly lineage: OrchestrationV2ThreadShell["lineage"]; readonly forkedFrom: OrchestrationV2ThreadShell["forkedFrom"]; readonly linkOrigin: OrchestrationV2ThreadShell["linkOrigin"] | null; /** The thread in a linked environment that delegated this one, if one did. */ readonly delegatedFrom: OrchestrationV2ThreadShell["delegatedFrom"] | null; readonly activeProviderThreadId: OrchestrationV2ThreadShell["activeProviderThreadId"]; readonly latestRun: ThreadRunSummary | null; readonly runtime: ThreadRuntimeSummary | null; readonly blockedOnThreadWait?: boolean; readonly latestUserMessageAt: string | null; readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; readonly pendingSubagentRequest?: PendingSubagentRequest | null; /** Guest messages waiting for the owner to send or dismiss. */ readonly pendingGuestMessageCount?: number; readonly hasActionableProposedPlan: boolean; readonly pendingBackgroundTasks: ReadonlyArray< NonNullable[number] >; readonly providerInstanceHistory: ReadonlyArray; readonly goal: OrchestrationV2ProviderGoal | null; readonly itemCount: number; readonly visibleItemCount: number; readonly createdAt: string; readonly updatedAt: string; readonly archivedAt: string | null; readonly settledOverride: "settled" | "active" | null; readonly settledAt: string | null; readonly unsettledAt: string | null; readonly snoozedUntil: string | null; readonly snoozedAt: string | null; readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; readonly pinnedAt: string | null; readonly autoSettleDisabledAt?: string | null; readonly pinOrderKey: string | null; readonly activeOrderKey: string | null; readonly pullRequests: ReadonlyArray; readonly linkedPullRequest?: ThreadLinkedPullRequest | null; readonly branchPullRequest?: ThreadLinkedPullRequest | null; /** * Server-tracked visited watermark. `undefined` means the environment's * server predates visited tracking and clients should fall back to any * local visited state they keep. */ readonly lastVisitedAt?: string | null; /** Server-tracked open history for search ranking. */ readonly opens?: import("@t3tools/contracts").OrchestrationV2ThreadOpens | null; readonly titleRegeneration?: { readonly requestId: string; readonly startedAt: string } | null; readonly deletedAt: string | null; readonly source: OrchestrationV2ThreadShell; } function iso(value: DateTime.Utc): string { return DateTime.formatIso(value); } function nullableIso(value: DateTime.Utc | null): string | null { return value === null ? null : iso(value); } function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { return ( status === "completed" || status === "interrupted" || status === "failed" || status === "cancelled" || status === "rolled_back" ); } // Only work the agent waits on holds it: commands and monitors it left running // unmarked, such as a dev server, present the run's own status. The // server suppresses the roster while an interruptible activity run exists, so a // remaining roster is stronger than checkpoint-oriented waiting. function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { const parkAtIdle = backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && thread.status !== "failed"; if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) { return null; } const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); return { status, activeRunId: thread.activeRunId, activityStartedAt: thread.activityRunStartedAt === undefined ? undefined : nullableIso(thread.activityRunStartedAt), providerInstanceId: thread.providerInstanceId, providerName: null, lastError: thread.lastError ?? null, lastErrorClass: thread.lastErrorClass ?? null, lastErrorCode: thread.lastErrorCode ?? null, usageLimitResetAt: thread.usageLimitResetAt ?? null, updatedAt: iso(thread.updatedAt), }; } export function scopeProject( environmentId: EnvironmentId, project: OrchestrationProjectShell, ): EnvironmentProject { return { ...project, environmentId }; } export type PendingSubagentRequest = "approval" | "user_input"; export function presentThreadShell( environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell, pendingSubagentRequest: PendingSubagentRequest | null = null, ): EnvironmentThreadShell { const updatedAt = iso(thread.updatedAt); const latestRun = thread.latestRunId === null ? null : ({ runId: thread.latestRunId, status: thread.status === "idle" ? "completed" : thread.status, requestedAt: nullableIso(thread.latestRunRequestedAt ?? null), startedAt: nullableIso(thread.latestRunStartedAt ?? null), completedAt: thread.latestRunCompletedAt === undefined ? thread.status === "idle" || terminalRunStatus(thread.status) ? updatedAt : null : nullableIso(thread.latestRunCompletedAt), assistantMessageId: null, } satisfies ThreadRunSummary); const runtime = shellRuntime(thread); // Waiting runs have ended their provider turn and are only draining effects. const runtimeIsWorking = runtime?.status === "preparing" || runtime?.status === "starting" || runtime?.status === "running"; return { environmentId, id: thread.id, projectId: thread.projectId, title: thread.lineage.relationshipToParent === "subagent" ? formatSubagentDisplayTitle(thread.title) : thread.title, providerInstanceId: thread.providerInstanceId, modelSelection: thread.modelSelection, runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, pullRequests: threadPullRequestsOf(thread), linkedPullRequest: thread.linkedPullRequest ?? null, branchPullRequest: thread.branchPullRequest ?? null, lineage: thread.lineage, forkedFrom: thread.forkedFrom, linkOrigin: thread.linkOrigin ?? null, delegatedFrom: thread.delegatedFrom ?? null, activeProviderThreadId: thread.activeProviderThreadId, latestRun, runtime, blockedOnThreadWait: thread.blockedOnThreadWait ?? false, latestUserMessageAt: nullableIso(thread.latestUserMessageAt), ...(thread.latestUserAuthoredMessageAt === undefined ? {} : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }), hasPendingApprovals: thread.pendingRuntimeRequest !== null && thread.pendingRuntimeRequest.kind !== "user_input" && thread.pendingRuntimeRequest.kind !== "auth_refresh", hasPendingUserInput: thread.pendingRuntimeRequest?.kind === "user_input" && (thread.pendingRuntimeRequest.blocking !== false || !runtimeIsWorking), pendingSubagentRequest, pendingGuestMessageCount: thread.pendingGuestMessageCount ?? 0, hasActionableProposedPlan: thread.hasActionableProposedPlan, pendingBackgroundTasks: thread.pendingBackgroundTasks ?? [], providerInstanceHistory: thread.providerInstanceHistory ?? [], goal: thread.goal ?? null, itemCount: thread.itemCount, visibleItemCount: thread.visibleItemCount, createdAt: iso(thread.createdAt), updatedAt, archivedAt: nullableIso(thread.archivedAt), settledOverride: thread.settledOverride, settledAt: nullableIso(thread.settledAt), unsettledAt: nullableIso(thread.unsettledAt ?? null), snoozedUntil: nullableIso(thread.snoozedUntil ?? null), snoozedAt: nullableIso(thread.snoozedAt ?? null), limitRecovery: thread.limitRecovery ?? null, pinnedAt: nullableIso(thread.pinnedAt ?? null), autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null), pinOrderKey: thread.pinOrderKey ?? null, activeOrderKey: thread.activeOrderKey ?? null, ...(thread.lastVisitedAt === undefined ? {} : { lastVisitedAt: nullableIso(thread.lastVisitedAt) }), opens: thread.opens ?? null, titleRegeneration: thread.titleRegeneration == null ? null : { requestId: thread.titleRegeneration.requestId, startedAt: iso(thread.titleRegeneration.startedAt), }, deletedAt: nullableIso(thread.deletedAt), source: thread, }; } export function scopeThreadShell( environmentId: EnvironmentId, thread: OrchestrationV2ThreadShell, ): EnvironmentThreadShell { return presentThreadShell(environmentId, thread); } const THREAD_PROVIDER_STACK_LIMIT = 3; export function resolveThreadProviderStack( thread: Pick, ): ReadonlyArray { const current = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; const previous = thread.providerInstanceHistory.filter((instanceId) => instanceId !== current); return [...previous.slice(-(THREAD_PROVIDER_STACK_LIMIT - 1)), current]; } /** * Both shell and detail timers count from the activity-owning run's work * start, never last activity. A wake keeps the start of the work it continues. */ export function resolveThreadWorkingStartedAt(input: { readonly latestRun: Pick< ThreadRunSummary, "runId" | "startedAt" | "requestedAt" | "completedAt" > | null; readonly runtime: Pick | null; }): string | null { const valid = (value: string | null | undefined) => value != null && Number.isFinite(Date.parse(value)) ? value : null; if (input.runtime?.activityStartedAt !== undefined) return valid(input.runtime.activityStartedAt); const run = input.latestRun; if (run?.completedAt === null && run.runId === input.runtime?.activeRunId) { return valid(run.startedAt) ?? valid(run.requestedAt); } return null; } export function formatWorkingDurationLabel(elapsedMs: number): string { const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0; if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; }