import { pipe } from "effect/Function"; import * as Arr from "effect/Array"; import * as O from "effect/Order"; import type { MessageId, OrchestrationCheckpointSummary, OrchestrationEvent, OrchestrationLatestTurn, OrchestrationMessage, OrchestrationSession, OrchestrationThread, OrchestrationThreadActivity, ThreadPullRequestLink, TurnId, } from "@t3tools/contracts"; import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequests"; import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } | { readonly kind: "deleted" } | { readonly kind: "unchanged" }; /** Keep only a legacy route supplied by the server; detail events cannot resolve project hosts. */ function withPullRequests( thread: OrchestrationThread, pullRequests: ReadonlyArray, updatedAt: string, ): ThreadDetailReducerResult { return { kind: "updated", thread: { ...thread, pullRequests, linkedPullRequest: thread.linkedPullRequest && pullRequests.some( (link) => link.source !== "stack-dismissed" && link.url === thread.linkedPullRequest?.url, ) ? thread.linkedPullRequest : null, updatedAt, }, }; } const proposedPlanOrder = O.combine( O.mapInput(O.String, (p) => p.createdAt), O.mapInput(O.String, (p) => p.id), ); const checkpointOrder = O.mapInput( O.Number, (cp: OrchestrationThread["checkpoints"][number]) => cp.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER, ); const activityOrder = O.combineAll([ O.mapInput(O.Number, (a) => a.sequence ?? Number.MAX_SAFE_INTEGER), O.mapInput(O.String, (a) => a.createdAt), O.mapInput(O.String, (a) => a.id), ]); // Per-array id index so the streaming append path can reject a re-delivered // id without rescanning the history. Only arrays this reducer produced are // indexed: presence also proves the array is activityOrder-sorted, which // snapshot-loaded arrays (DB order, null sequences first) are not. const activityIdIndex = new WeakMap< ReadonlyArray, Set >(); /** * Matches the validity rule in `deriveLatestContextWindowSnapshot` (and the * server's snapshot-side `dropStaleContextWindowActivities`): rows without a * finite, non-negative `usedTokens` are skipped during the consumer's backward * walk, so they must not replace an earlier resolvable row here. */ function isResolvableContextWindowActivity(activity: OrchestrationThreadActivity): boolean { if (activity.kind !== "context-window.updated") { return false; } const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) : null; const usedTokens = payload?.usedTokens; return typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens >= 0; } /** * Apply a single orchestration event to an `OrchestrationThread`, returning * the updated thread, a deletion signal, or an "unchanged" marker when the * event doesn't affect this thread. * * This is a pure reducer operating on contract types. UI-specific mapping * (e.g. resolving attachment preview URLs, normalising model slugs, adding * scoped fields like `environmentId`) is the caller's responsibility. */ export function applyThreadDetailEvent( thread: OrchestrationThread, event: OrchestrationEvent, ): ThreadDetailReducerResult { switch (event.type) { // ── Project events (irrelevant to thread detail) ──────────────── case "project.created": case "project.meta-updated": case "project.deleted": return { kind: "unchanged" }; // ── Thread lifecycle ──────────────────────────────────────────── case "thread.created": return { kind: "updated", thread: { id: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, branchPullRequest: null, latestTurn: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, archivedAt: null, settledOverride: null, settledAt: null, unsettledAt: null, activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, pullRequests: [], messages: [], proposedPlans: [], activities: [], checkpoints: [], session: null, }, }; case "thread.deleted": return { kind: "deleted" }; case "thread.archived": return { kind: "updated", thread: { ...thread, archivedAt: event.payload.archivedAt, titleRegeneration: null, updatedAt: event.payload.updatedAt, }, }; case "thread.unarchived": return { kind: "updated", thread: { ...thread, archivedAt: null, updatedAt: event.payload.updatedAt }, }; case "thread.settled": return { kind: "updated", thread: { ...thread, settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, activeOrderKey: null, updatedAt: event.payload.updatedAt, }, }; case "thread.unsettled": return { kind: "updated", thread: { ...thread, settledOverride: event.payload.reason === "user" ? "active" : null, settledAt: null, // A thread already pinned active keeps its re-entry stamp: the // activity reset that clears the pin must not reorder the list. unsettledAt: thread.settledOverride === "active" ? (thread.unsettledAt ?? null) : event.payload.updatedAt, updatedAt: event.payload.updatedAt, }, }; case "thread.snoozed": return { kind: "updated", thread: { ...thread, snoozedUntil: event.payload.snoozedUntil, snoozedAt: event.payload.snoozedAt, updatedAt: event.payload.updatedAt, }, }; case "thread.unsnoozed": return { kind: "updated", thread: { ...thread, snoozedUntil: null, snoozedAt: null, updatedAt: event.payload.updatedAt, }, }; case "thread.pinned": return { kind: "updated", thread: { ...thread, pinnedAt: event.payload.pinnedAt, ...(event.payload.pinOrderKey !== undefined ? { pinOrderKey: event.payload.pinOrderKey } : {}), updatedAt: event.payload.updatedAt, }, }; case "thread.unpinned": return { kind: "updated", thread: { ...thread, pinnedAt: null, pinOrderKey: null, updatedAt: event.payload.updatedAt, }, }; case "thread.pin-reordered": return { kind: "updated", thread: { ...thread, pinOrderKey: event.payload.orderKey, updatedAt: event.payload.updatedAt, }, }; // ── Thread metadata ───────────────────────────────────────────── case "thread.meta-updated": return { kind: "updated", thread: { ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegeneration: event.payload.titleRegeneration } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), ...(event.payload.linkedPullRequest !== undefined ? { linkedPullRequest: event.payload.linkedPullRequest } : {}), ...(event.payload.branchPullRequest !== undefined ? { branchPullRequest: event.payload.branchPullRequest } : {}), ...(event.payload.activeOrderKey !== undefined ? { activeOrderKey: event.payload.activeOrderKey } : {}), updatedAt: event.payload.updatedAt, }, }; case "thread.pull-request-linked": { const link = event.payload.link; const others = thread.pullRequests.filter( (existing) => !threadPullRequestKeysEqual(existing, link), ); return withPullRequests(thread, [...others, link], event.payload.updatedAt); } case "thread.pull-request-unlinked": return withPullRequests( thread, thread.pullRequests.filter( (existing) => !threadPullRequestKeysEqual(existing, event.payload), ), event.payload.updatedAt, ); case "thread.pull-request-synced": { if ( !thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, event.payload)) ) { return { kind: "unchanged" }; } return withPullRequests( thread, thread.pullRequests.map((existing) => threadPullRequestKeysEqual(existing, event.payload) ? { ...existing, snapshot: event.payload.snapshot, stack: event.payload.stack } : existing, ), event.payload.updatedAt, ); } case "thread.runtime-mode-set": return { kind: "updated", thread: { ...thread, runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, }, }; case "thread.interaction-mode-set": return { kind: "updated", thread: { ...thread, interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, }, }; // ── Turn lifecycle ────────────────────────────────────────────── case "thread.turn-start-requested": return { kind: "updated", thread: { ...thread, ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, updatedAt: event.occurredAt, }, }; case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { return { kind: "unchanged" }; } const latestTurn = thread.latestTurn; if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { return { kind: "unchanged" }; } return { kind: "updated", thread: { ...thread, latestTurn: { ...latestTurn, state: "interrupted", startedAt: latestTurn.startedAt ?? event.payload.createdAt, completedAt: latestTurn.completedAt ?? event.payload.createdAt, }, updatedAt: event.occurredAt, }, }; } // ── Messages ──────────────────────────────────────────────────── case "thread.message-sent": { const message: OrchestrationMessage = { id: event.payload.messageId, role: event.payload.role, text: event.payload.text, ...(event.payload.attachments !== undefined ? { attachments: event.payload.attachments } : {}), turnId: event.payload.turnId, streaming: event.payload.streaming, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, }; const existingMessage = thread.messages.find((entry) => entry.id === message.id); const messages = existingMessage ? Arr.map(thread.messages, (entry) => entry.id !== message.id ? entry : { ...entry, text: message.streaming ? `${entry.text}${message.text}` : message.text.length > 0 ? message.text : entry.text, streaming: message.streaming, ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), ...(message.streaming ? {} : { updatedAt: message.updatedAt }), ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), }, ) : Arr.append(thread.messages, message); // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn // (commentary between tool calls), and the turn must stay unsettled // until the provider reports turn end. Streaming deltas recompute the // same record, so the previous reference is kept when nothing changed. const turnStillRunning = event.payload.turnId !== null && thread.session?.status === "running" && thread.session.activeTurnId === event.payload.turnId; const settlesTurn = !event.payload.streaming && !turnStillRunning; const latestTurn = reuseLatestTurn( thread.latestTurn, event.payload.role === "assistant" && event.payload.turnId !== null && (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, state: settlesTurn ? thread.latestTurn?.state === "interrupted" ? "interrupted" : thread.latestTurn?.state === "error" ? "error" : "completed" : "running", requestedAt: thread.latestTurn?.turnId === event.payload.turnId ? thread.latestTurn.requestedAt : event.payload.createdAt, startedAt: thread.latestTurn?.turnId === event.payload.turnId ? (thread.latestTurn.startedAt ?? event.payload.createdAt) : event.payload.createdAt, completedAt: settlesTurn ? event.payload.updatedAt : thread.latestTurn?.turnId === event.payload.turnId ? (thread.latestTurn.completedAt ?? null) : null, assistantMessageId: event.payload.messageId, } : thread.latestTurn, ); // Rebind checkpoint assistant message IDs for assistant messages. The // helper hands back the same array when the entry is already bound. const checkpoints = event.payload.role === "assistant" && event.payload.turnId !== null ? rebindCheckpointAssistantMessage( thread.checkpoints, event.payload.turnId, event.payload.messageId, ) : thread.checkpoints; return { kind: "updated", thread: { ...thread, messages, checkpoints, latestTurn, updatedAt: event.occurredAt, }, }; } // ── Session ───────────────────────────────────────────────────── case "thread.session-set": { // Leaving the "running" session status is the turn-end signal: settle a // still-running latest turn so its duration reflects the whole turn. const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); const latestTurn = reuseLatestTurn( thread.latestTurn, event.payload.session.status === "running" && event.payload.session.activeTurnId !== null ? { turnId: event.payload.session.activeTurnId, state: "running", requestedAt: thread.latestTurn?.turnId === event.payload.session.activeTurnId ? thread.latestTurn.requestedAt : event.payload.session.updatedAt, startedAt: thread.latestTurn?.turnId === event.payload.session.activeTurnId ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) : event.payload.session.updatedAt, completedAt: null, assistantMessageId: thread.latestTurn?.turnId === event.payload.session.activeTurnId ? thread.latestTurn.assistantMessageId : null, } : thread.latestTurn !== null && thread.latestTurn.state === "running" && settledTurnState !== null ? { ...thread.latestTurn, state: settledTurnState, // A running turn's completedAt can only hold a mid-turn // placeholder checkpoint timestamp — the session leaving // "running" is the authoritative turn end. completedAt: event.payload.session.updatedAt, } : thread.latestTurn, ); return { kind: "updated", thread: { ...thread, session: event.payload.session, latestTurn, updatedAt: event.occurredAt, }, }; } case "thread.session-stop-requested": return thread.session === null ? { kind: "unchanged" } : { kind: "updated", thread: { ...thread, session: { ...thread.session, status: "stopped", activeTurnId: null, updatedAt: event.payload.createdAt, }, updatedAt: event.occurredAt, }, }; // ── Proposed plans ────────────────────────────────────────────── case "thread.proposed-plan-upserted": { const proposedPlan = event.payload.proposedPlan; const proposedPlans = pipe( thread.proposedPlans, Arr.filter((entry) => entry.id !== proposedPlan.id), Arr.append(proposedPlan), Arr.sort(proposedPlanOrder), ); return { kind: "updated", thread: { ...thread, proposedPlans, updatedAt: event.occurredAt }, }; } // ── Checkpoints / turn diffs ──────────────────────────────────── case "thread.turn-diff-completed": { const checkpoint: OrchestrationCheckpointSummary = { turnId: event.payload.turnId, checkpointTurnCount: event.payload.checkpointTurnCount, checkpointRef: event.payload.checkpointRef, status: event.payload.status, files: event.payload.files, assistantMessageId: event.payload.assistantMessageId, completedAt: event.payload.completedAt, }; const existing = thread.checkpoints.find((entry) => entry.turnId === checkpoint.turnId); // Don't overwrite a non-missing checkpoint with a missing one. if (existing && existing.status !== "missing" && checkpoint.status === "missing") { return { kind: "unchanged" }; } const checkpoints = pipe( thread.checkpoints, Arr.filter((entry) => entry.turnId !== checkpoint.turnId), Arr.append(checkpoint), Arr.sort(checkpointOrder), ); // Mid-turn diff updates produce placeholder checkpoints; record the // checkpoint, but don't settle a turn its session is still running. const diffTurnStillRunning = thread.session?.status === "running" && thread.session.activeTurnId === event.payload.turnId; const latestTurn = !diffTurnStillRunning && (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, state: thread.latestTurn?.state === "interrupted" ? "interrupted" : checkpointStatusToTurnState(event.payload.status), requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, completedAt: event.payload.completedAt, assistantMessageId: event.payload.assistantMessageId, } : thread.latestTurn; return { kind: "updated", thread: { ...thread, checkpoints, latestTurn, updatedAt: event.occurredAt }, }; } // ── Revert ────────────────────────────────────────────────────── case "thread.reverted": { const checkpoints = pipe( thread.checkpoints, Arr.filter( (entry) => entry.checkpointTurnCount !== undefined && entry.checkpointTurnCount <= event.payload.turnCount, ), Arr.sort(checkpointOrder), ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); const messages = retainMessagesAfterRevert( thread.messages, retainedTurnIds, event.payload.turnCount, ); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), ); const activities = pipe( thread.activities, Arr.filter((activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId)), ); const latestCheckpoint = checkpoints.at(-1) ?? null; return { kind: "updated", thread: { ...thread, checkpoints, messages, proposedPlans, activities, latestTurn: latestCheckpoint === null ? null : { turnId: latestCheckpoint.turnId, state: checkpointStatusToTurnState( latestCheckpoint.status as "ready" | "missing" | "error", ), requestedAt: latestCheckpoint.completedAt, startedAt: latestCheckpoint.completedAt, completedAt: latestCheckpoint.completedAt, assistantMessageId: latestCheckpoint.assistantMessageId ?? null, }, updatedAt: event.occurredAt, }, }; } // ── Activities ────────────────────────────────────────────────── case "thread.activity-appended": { const activity = event.payload.activity; // A resolvable context-window update supersedes earlier resolvable ones // for the same turn: consumers only read the latest value (walking the // array backwards), and providers stream these updates continuously, so // retaining the history grows the thread by thousands of rows over a // long session. Mirrors the server-side snapshot rule in // dropStaleContextWindowActivities; retention stays per turn so a // thread.reverted that discards turns can still resolve a value from // the turns that survive. const supersedesContextWindow = isResolvableContextWindowActivity(activity); // Live streams append in order: an unseen id sorting at/after the tail // of a known-sorted array appends without re-filtering and re-sorting // the whole history on every event. The id set moves forward to the new // array; a superseded array falls back to the sorting path. const ids = activityIdIndex.get(thread.activities); const lastActivity = thread.activities.at(-1); if ( !supersedesContextWindow && ids !== undefined && (lastActivity === undefined || activityOrder(lastActivity, activity) <= 0) && !ids.has(activity.id) ) { const activities = Arr.append(thread.activities, activity); activityIdIndex.delete(thread.activities); ids.add(activity.id); activityIdIndex.set(activities, ids); return { kind: "updated", thread: { ...thread, activities, updatedAt: event.occurredAt, }, }; } const activities = pipe( thread.activities, Arr.filter( (entry) => entry.id !== activity.id && !( supersedesContextWindow && entry.turnId === activity.turnId && isResolvableContextWindowActivity(entry) ), ), Arr.append(activity), Arr.sort(activityOrder), ); activityIdIndex.set(activities, new Set(activities.map((entry) => entry.id))); return { kind: "updated", thread: { ...thread, activities, updatedAt: event.occurredAt }, }; } // ── Events that don't mutate thread state directly ────────────── case "thread.approval-response-requested": case "thread.user-input-response-requested": case "thread.checkpoint-revert-requested": return { kind: "unchanged" }; } // Forward-compatible: ignore unrecognized event types. return { kind: "unchanged" }; } // ── Helpers ────────────────────────────────────────────────────────── /** * Turn state to settle a still-running latest turn with when its session * leaves the "running" status, or null while the session is (re)starting or * running and the turn must stay unsettled. */ function settledTurnStateForSessionStatus( status: OrchestrationSession["status"], ): "completed" | "interrupted" | "error" | null { switch (status) { case "idle": case "ready": return "completed"; case "error": return "error"; case "interrupted": case "stopped": return "interrupted"; case "starting": case "running": return null; } } function checkpointStatusToTurnState( status: "ready" | "missing" | "error", ): OrchestrationLatestTurn["state"] { switch (status) { case "ready": return "completed"; case "error": return "error"; case "missing": return "completed"; } } /** * Returns `previous` when `next` matches it field for field, otherwise `next`. * Streaming cases recompute the latest turn on every delta, and keeping the * old reference lets selectors and memos keyed on `latestTurn` skip work. */ function reuseLatestTurn( previous: OrchestrationLatestTurn | null, next: OrchestrationLatestTurn | null, ): OrchestrationLatestTurn | null { if (previous === null || next === null) { return next; } return previous.turnId === next.turnId && previous.state === next.state && previous.requestedAt === next.requestedAt && previous.startedAt === next.startedAt && previous.completedAt === next.completedAt && previous.assistantMessageId === next.assistantMessageId && previous.sourceProposedPlan?.threadId === next.sourceProposedPlan?.threadId && previous.sourceProposedPlan?.planId === next.sourceProposedPlan?.planId ? previous : next; } /** * Points the checkpoint for `turnId` at `messageId`. Returns the input array * untouched when no checkpoint needs rebinding, so streaming deltas for an * already-bound message do not allocate a new `checkpoints` reference. */ function rebindCheckpointAssistantMessage( checkpoints: ReadonlyArray, turnId: TurnId, messageId: MessageId, ): ReadonlyArray { const needsRebind = checkpoints.some( (entry) => entry.turnId === turnId && entry.assistantMessageId !== messageId, ); if (!needsRebind) { return checkpoints; } return Arr.map(checkpoints, (entry) => entry.turnId === turnId ? { ...entry, assistantMessageId: messageId } : entry, ); } function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, turnCount: number, ): OrchestrationMessage[] { const retainedMessageIds = new Set(); for (const message of messages) { if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { retainedMessageIds.add(message.id); } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { retainedMessageIds.add(message.id); } } for (const role of ["user", "assistant"] as const) { const retainedCount = messages.filter( (message) => message.role === role && !isImportedAgentSessionMessageId(message.id) && retainedMessageIds.has(message.id), ).length; const missingCount = Math.max(0, turnCount - retainedCount); const fallbackMessages = messages .filter( (message) => message.role === role && !retainedMessageIds.has(message.id) && (message.turnId === null || retainedTurnIds.has(message.turnId)), ) .toSorted( (left, right) => compareDateTimeStrings(left.createdAt, right.createdAt) || left.id.localeCompare(right.id), ) .slice(0, missingCount); for (const message of fallbackMessages) { retainedMessageIds.add(message.id); } } return Arr.filter(messages, (message) => retainedMessageIds.has(message.id)); }