Skip to content
Source
Sign inSign up

session-logic.ts

1,126 lines37.6 KB

1import type { ThreadStructureRuntime } from "@t3tools/client-runtime/state/shell";
2import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
3import {
4 type AssetResource,
5 type OrchestrationV2Checkpoint,
6 type OrchestrationV2ExecutionNode,
7 type OrchestrationV2PlanArtifact,
8 type OrchestrationV2ProjectedTurnItem,
9 type OrchestrationV2RunAttempt,
10 type OrchestrationV2TurnItem,
11 type RunId,
12 type ToolActivitySurface,
13 type ToolActivityIcon,
14 type ToolActivitySource,
15 type ThreadId,
16 type TurnItemId,
17} from "@t3tools/contracts";
18import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
19import {
20 classifyToolActivity,
21 collectToolFilePaths,
22 formatReadToolLabel,
23 formatSearchToolLabel,
24} from "@t3tools/shared/toolActivity";
25import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
26import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
27import type { McpAppReference } from "@t3tools/shared/mcpApp";
28import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
29import {
30 contextCompactionLabel,
31 workEntryIndicatesToolFailure,
32} from "@t3tools/client-runtime/work-log/presentation";
33import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
34import type {
35 ThreadPendingApproval,
36 ThreadPendingUserInput,
37} from "@t3tools/client-runtime/state/thread-requests";
38import type { ThreadRunSummary, ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
39import { threadRuntimeHasInterruptibleRun } from "@t3tools/client-runtime/state/thread-execution";
40import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
41​
42import {
43 isImageAttachment,
44 type ChatAttachment,
45 type ChatMessage,
46 type ProposedPlan,
47 type SessionPhase,
48 type TurnDiffSummary,
49} from "./types";
50import * as DateTime from "effect/DateTime";
51import * as Equal from "effect/Equal";
52import { shallow } from "zustand/vanilla/shallow";
53​
54export { formatDuration } from "@t3tools/shared/orchestrationTiming";
55export {
56 workEntryDisplayIndicatesToolFailure,
57 workEntryIndicatesToolFailure,
58} from "@t3tools/client-runtime/work-log/presentation";
59​
60export type WorkLogToolLifecycleStatus =
61 | "idle"
62 | "inProgress"
63 | "completed"
64 | "failed"
65 | "declined"
66 | "stopped";
67​
68export interface WorkLogEntry {
69 readonly questionAnswer?: import("@t3tools/contracts").UserInputAttachmentAnswerPayload;
70 readonly id: string;
71 readonly createdAt: string;
72 readonly runId?: RunId | null;
73 readonly label: string;
74 readonly detail?: string;
75 readonly command?: string;
76 readonly rawCommand?: string;
77 readonly changedFiles?: ReadonlyArray<string>;
78 readonly tone: "thinking" | "tool" | "info" | "error";
79 readonly toolTitle?: string;
80 readonly toolCallId?: string;
81 readonly viewedImagePath?: string;
82 readonly toolSurface?: ToolActivitySurface;
83 readonly toolIcon?: ToolActivityIcon;
84 readonly toolSource?: ToolActivitySource;
85 readonly sourceActivityKind?: string;
86 readonly taskId?: string;
87 readonly agentRole?: string;
88 readonly toolData?: unknown;
89 readonly requestKind?: string;
90 readonly itemType?: OrchestrationV2TurnItem["type"];
91 readonly toolLifecycleStatus?: WorkLogToolLifecycleStatus;
92 readonly structuredPayload?: OrchestrationV2TurnItem;
93 readonly sourceItemType?: OrchestrationV2TurnItem["type"];
94 readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
96​
97export type PendingApproval = ThreadPendingApproval;
98export type PendingUserInput = ThreadPendingUserInput;
99​
100export type TimelineAttempt = Pick<
101 OrchestrationV2RunAttempt,
102 "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt"
103>;
104​
105export type TimelineEntry = (
106 | {
107 readonly id: string;
108 readonly kind: "message";
109 readonly createdAt: string;
110 readonly message: ChatMessage;
111 readonly projectedItem?: OrchestrationV2ProjectedTurnItem;
112 }
113 | {
114 readonly id: string;
115 readonly kind: "proposed-plan";
116 readonly createdAt: string;
117 readonly proposedPlan: ProposedPlan;
118 }
119 | {
120 readonly id: string;
121 readonly kind: "html-render";
122 readonly createdAt: string;
123 readonly runId: RunId | null;
124 readonly htmlRender: HtmlRenderReference;
125 }
126 | {
127 readonly id: string;
128 readonly kind: "mcp-app";
129 readonly createdAt: string;
130 readonly runId: RunId | null;
131 readonly sourceThreadId: ThreadId;
132 readonly itemId: TurnItemId;
133 readonly revision: string;
134 readonly mcpApp: McpAppReference;
135 }
136 | {
137 readonly id: string;
138 readonly kind: "work";
139 readonly createdAt: string;
140 readonly entry: WorkLogEntry;
141 }
142 | {
143 readonly id: string;
144 readonly kind: "event";
145 readonly createdAt: string;
146 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
147 }
148) & {
149 readonly attempt?: TimelineAttempt;
150};
151​
152export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean {
153 return (
154 entry.tone === "tool" ||
155 entry.tone === "thinking" ||
156 entry.tone === "error" ||
157 entry.command !== undefined ||
158 entry.requestKind !== undefined
159 );
161​
162export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean {
163 return entry.itemType === "error";
165​
166export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean {
167 if (
168 !workLogEntryIsToolLike(entry) ||
169 workEntryIndicatesToolFailure(entry) ||
170 (entry.tone === "thinking" && entry.itemType !== "reasoning")
171 ) {
172 return false;
173 }
174 const status = entry.toolLifecycleStatus;
175 return (
176 status !== "failed" &&
177 status !== "declined" &&
178 status !== "inProgress" &&
179 status !== "stopped" &&
180 status !== "idle"
181 );
183​
184export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean {
185 return (
186 workLogEntryIsToolLike(entry) &&
187 !workEntryIndicatesToolFailure(entry) &&
188 !workEntryIndicatesToolSuccess(entry)
189 );
191​
192export function isLatestRunSettled(
193 latestRun: Pick<ThreadRunSummary, "runId" | "startedAt" | "completedAt" | "status"> | null,
194 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId"> | null,
195): boolean {
196 if (latestRun === null) return false;
197 if (
198 latestRun.status === "preparing" ||
199 latestRun.status === "queued" ||
200 latestRun.status === "starting" ||
201 latestRun.status === "running" ||
202 latestRun.status === "waiting"
203 )
204 return false;
205 return runtime?.activeRunId !== latestRun.runId;
207​
208export function deriveActiveWorkStartedAt(
209 latestRun: Pick<
210 ThreadRunSummary,
211 "runId" | "startedAt" | "requestedAt" | "completedAt" | "status"
212 > | null,
213 runtime: Pick<ThreadRuntimeSummary, "status" | "activeRunId" | "activityStartedAt"> | null,
214 sendStartedAt: string | null,
215): string | null {
216 const startedAt = resolveThreadWorkingStartedAt({ latestRun, runtime });
217 return startedAt ?? (runtime?.activeRunId == null ? sendStartedAt : null);
219​
220export function derivePendingApprovals(
221 approvals: ReadonlyArray<ThreadPendingApproval>,
222): ThreadPendingApproval[] {
223 return [...approvals].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
225​
226export function derivePendingUserInputs(
227 inputs: ReadonlyArray<ThreadPendingUserInput>,
228): ThreadPendingUserInput[] {
229 return [...inputs].toSorted((left, right) => left.createdAt.localeCompare(right.createdAt));
231​
232const STANDALONE_V2_ITEM_TYPES = new Set<OrchestrationV2ProjectedTurnItem["item"]["type"]>([
233 "fork",
234 "handoff",
235 "run_interrupt_request",
236 "run_interrupt_result",
237 "secret_request",
238 "link_request",
239 "subagent",
240]);
241​
242const PERSISTENT_RESOURCE_V2_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
243 "fork",
244 "thread_created",
245 // Still answerable after a steer supersedes the attempt that asked.
246 "secret_request",
247 "link_request",
248]);
249​
250export function timelineEntryIsPersistentResourceCard(entry: TimelineEntry): boolean {
251 return (
252 entry.kind === "event" && PERSISTENT_RESOURCE_V2_ITEM_TYPES.has(entry.projectedItem.item.type)
253 );
255​
256function projectedItemCreatedAt(row: OrchestrationV2ProjectedTurnItem): string {
257 return DateTime.formatIso(row.item.startedAt ?? row.item.updatedAt);
259​
260function projectedWorkEntryStatus(
261 item: OrchestrationV2TurnItem,
262): NonNullable<WorkLogEntry["toolLifecycleStatus"]> {
263 switch (item.status) {
264 case "pending":
265 case "running":
266 case "waiting":
267 return "inProgress";
268 case "completed":
269 return "completed";
270 case "idle":
271 return "idle";
272 case "failed":
273 return "failed";
274 case "cancelled":
275 case "interrupted":
276 return "stopped";
277 }
279​
280function projectedWorkEntryTone(item: OrchestrationV2TurnItem): WorkLogEntry["tone"] {
281 if (item.type === "error") return "info";
282 if (item.type === "reasoning") return "thinking";
283 switch (item.type) {
284 case "command_execution":
285 case "file_change":
286 case "file_search":
287 case "web_search":
288 case "dynamic_tool":
289 case "subagent":
290 case "thread_created":
291 case "user_input_request":
292 case "approval_request":
293 return "tool";
294 default:
295 return "info";
296 }
298​
299export function providerErrorPresentation(
300 item: Extract<OrchestrationV2TurnItem, { readonly type: "error" }>,
301): { readonly label: string; readonly detail: string } {
302 if (item.retry === undefined) {
303 return {
304 label:
305 item.failure.class === "usage_limit"
306 ? "Usage limit reached"
307 : item.title?.trim() || "Provider error",
308 detail: item.failure.message,
309 };
310 }
311 const progress =
312 item.retry.maxAttempts === null
313 ? `${item.retry.attempt}`
314 : `${item.retry.attempt}/${item.retry.maxAttempts}`;
315 const label =
316 item.status === "running"
317 ? `Retrying provider (${progress})`
318 : item.status === "completed"
319 ? `Provider recovered (${progress} retries)`
320 : item.status === "failed"
321 ? `${item.failure.class === "usage_limit" ? "Usage limit reached" : "Provider error"} after ${progress} retries`
322 : `Provider retry stopped (${progress})`;
323 const retryDelay =
324 item.status === "running" && item.retry.retryDelayMs !== null && item.retry.retryDelayMs > 0
325 ? item.retry.retryDelayMs < 1_000
326 ? ` Retrying in ${item.retry.retryDelayMs}ms.`
327 : ` Retrying in ${(item.retry.retryDelayMs / 1_000).toFixed(1).replace(/\.0$/u, "")}s.`
328 : "";
329 return {
330 label,
331 detail: `${item.failure.message}${retryDelay}`,
332 };
334​
335function projectedWorkEntry(row: OrchestrationV2ProjectedTurnItem): WorkLogEntry {
336 const { item } = row;
337 const title = item.title?.trim() || null;
338 const common = {
339 id: item.id,
340 createdAt: projectedItemCreatedAt(row),
341 runId: item.runId,
342 tone: projectedWorkEntryTone(item),
343 itemType: item.type,
344 toolLifecycleStatus: projectedWorkEntryStatus(item),
345 structuredPayload: item,
346 projectedItem: row,
347 ...extractToolActivityPresentation(item),
348 } as const;
349​
350 switch (item.type) {
351 case "thread_created":
352 return {
353 ...common,
354 label: "Created thread",
355 };
356 case "compaction":
357 return {
358 ...common,
359 label: contextCompactionLabel(item),
360 sourceActivityKind: "context-compaction",
361 ...(item.summary ? { detail: item.summary } : {}),
362 };
363 case "reasoning":
364 return {
365 ...common,
366 label: title ?? "Thinking",
367 ...(item.text ? { detail: item.text } : {}),
368 };
369 case "command_execution":
370 return {
371 ...common,
372 label: title ?? "Ran command",
373 command: item.input,
374 rawCommand: item.input,
375 toolTitle: title ?? "Command",
376 toolData: item,
377 };
378 case "file_change": {
379 return {
380 ...common,
381 label:
382 title ??
383 (item.changes !== undefined && item.changes.length > 1
384 ? `Changed ${item.changes.length} files`
385 : `Changed ${item.fileName}`),
386 changedFiles: item.changes?.map((change) => change.path) ?? [item.fileName],
387 toolTitle: title ?? "File change",
388 toolData: item,
389 };
390 }
391 case "file_search":
392 return {
393 ...common,
394 label: title ?? formatSearchToolLabel(item) ?? "Searched files",
395 ...(item.pattern ? { detail: item.pattern } : {}),
396 toolTitle: title ?? "File search",
397 toolData: item,
398 };
399 case "web_search":
400 return {
401 ...common,
402 label: title ?? "Searched the web",
403 ...(item.patterns?.length ? { detail: item.patterns.join(", ") } : {}),
404 toolTitle: title ?? "Web search",
405 toolData: item,
406 };
407 case "checkpoint":
408 return {
409 ...common,
410 label: title ?? "Checkpoint captured",
411 changedFiles: item.files.map((file) => file.path),
412 toolData: item,
413 };
414 case "system_notice":
415 return {
416 ...common,
417 label: item.message,
418 sourceActivityKind: "runtime.warning",
419 };
420 case "error": {
421 const presentation = providerErrorPresentation(item);
422 return {
423 ...common,
424 ...presentation,
425 ...(item.failure.class === "usage_limit" && item.status !== "completed"
426 ? { sourceActivityKind: "runtime.warning" }
427 : item.retry === undefined
428 ? { sourceActivityKind: "runtime.error" }
429 : {}),
430 toolData: item,
431 };
432 }
433 case "dynamic_tool": {
434 const classified = classifyToolActivity({
435 itemType: "dynamic_tool_call",
436 data: { toolName: item.toolName ?? undefined, input: item.input },
437 });
438 const [readPath] = collectToolFilePaths({ input: item.input });
439 return {
440 ...common,
441 label:
442 title ??
443 (classified === "read"
444 ? formatReadToolLabel(readPath ?? "")
445 : classified === "search"
446 ? (formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call")
447 : (item.toolName ?? "Tool call")),
448 toolTitle: title ?? item.toolName ?? "Tool",
449 toolData: { input: item.input, output: item.output },
450 };
451 }
452 case "todo_list": {
453 // Each todo update is a quiet row where the agent wrote it; it opens to every step.
454 const done = item.steps.filter((step) => step.status === "completed").length;
455 const current =
456 item.steps.find((step) => step.status === "running") ??
457 item.steps.find((step) => step.status === "pending");
458 return {
459 ...common,
460 label: `Tasks ${done}/${item.steps.length}${current ? ` · ${current.text}` : ""}`,
461 };
462 }
463 case "approval_request":
464 return {
465 ...common,
466 label: title ?? "Approval requested",
467 detail: item.prompt ?? item.requestKind,
468 toolData: item,
469 };
470 case "user_input_request":
471 return {
472 ...common,
473 label: title ?? (item.questionAnswer ? "Answered questions" : "Input requested"),
474 ...(item.questionAnswer ? { questionAnswer: item.questionAnswer } : {}),
475 toolData: item,
476 };
477 default:
478 return {
479 ...common,
480 label: title ?? item.type.replaceAll("_", " "),
481 toolData: item,
482 };
483 }
485​
486/** Preserve visibleTurnItems' committed order; interleave client messages by timestamp. */
487export interface TimelineEntriesInput {
488 readonly visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>;
489 readonly optimisticMessages: ReadonlyArray<ChatMessage>;
490 readonly anchoredMessages?: ReadonlyArray<ChatMessage>;
491 readonly attachmentUrlById?: ReadonlyMap<string, string>;
492 readonly attempts?: ReadonlyArray<OrchestrationV2RunAttempt>;
493 readonly nodes?: ReadonlyArray<OrchestrationV2ExecutionNode>;
494 readonly plans?: ReadonlyArray<OrchestrationV2PlanArtifact>;
496​
497export interface TimelineEntriesProjection {
498 readonly input: TimelineEntriesInput;
499 readonly entries: TimelineEntry[];
501​
502export function deriveTimelineEntriesFromVisibleTurnItems(
503 input: TimelineEntriesInput,
504): TimelineEntry[] {
505 const committedMessageIds = new Set<string>();
506 const entries: TimelineEntry[] = [];
507 const attemptByRootNodeId = new Map(
508 (input.attempts ?? []).map((attempt) => [attempt.rootNodeId, attempt] as const),
509 );
510 const nodeById = new Map((input.nodes ?? []).map((node) => [node.id, node] as const));
511 const planById = new Map((input.plans ?? []).map((plan) => [plan.id, plan] as const));
512​
513 const resolveAttempt = (item: OrchestrationV2TurnItem): TimelineAttempt | undefined => {
514 if (item.nodeId === null || item.runId === null) return undefined;
515 let nodeId: OrchestrationV2ExecutionNode["id"] | null = item.nodeId;
516 const visited = new Set<OrchestrationV2ExecutionNode["id"]>();
517 while (nodeId !== null && !visited.has(nodeId)) {
518 visited.add(nodeId);
519 const directAttempt = attemptByRootNodeId.get(nodeId);
520 if (directAttempt?.runId === item.runId) return directAttempt;
521 const node = nodeById.get(nodeId);
522 if (node === undefined) return undefined;
523 const rootAttempt = attemptByRootNodeId.get(node.rootNodeId);
524 if (rootAttempt?.runId === item.runId) return rootAttempt;
525 nodeId = node.parentNodeId;
526 }
527 return undefined;
528 };
529​
530 // An answer that started a later run stays a normal message at that run's position.
531 const foldedAnswerRunIdByMessageId = new Map<string, OrchestrationV2TurnItem["runId"]>(
532 input.visibleTurnItems.flatMap(({ item }) =>
533 item.type === "user_input_request" && item.questionAnswer
534 ? [[`async-answer:${item.questionAnswer.requestId}`, item.runId] as const]
535 : [],
536 ),
537 );
538 for (const row of input.visibleTurnItems) {
539 const { item } = row;
540 if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue;
541 if (item.type === "checkpoint") continue;
542 if (
543 item.type === "user_message" &&
544 item.inputIntent === "queued_turn" &&
545 item.status === "pending"
546 )
547 continue;
548 if (
549 item.type === "user_message" &&
550 foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId
551 )
552 continue;
553 const createdAt = projectedItemCreatedAt(row);
554 const attempt = resolveAttempt(item);
555 const attemptMetadata = attempt === undefined ? {} : { attempt };
556 if (item.type === "notification") {
557 entries.push({
558 id: item.id,
559 kind: "work",
560 createdAt,
561 entry: {
562 id: item.id,
563 createdAt,
564 runId: item.runId,
565 label: item.summary,
566 tone: "info",
567 itemType: item.type,
568 structuredPayload: item,
569 projectedItem: row,
570 },
571 ...attemptMetadata,
572 });
573 continue;
574 }
575 if (item.type === "user_message" || item.type === "assistant_message") {
576 const message: ChatMessage = {
577 id: item.messageId,
578 role: item.type === "user_message" ? "user" : "assistant",
579 text: item.text,
580 ...(item.type === "user_message" && item.context ? { context: item.context } : {}),
581 ...((item.attachments?.length ?? 0) > 0
582 ? {
583 attachments: (item.attachments ?? []).map((attachment) => {
584 const previewUrl = input.attachmentUrlById?.get(attachment.id);
585 return previewUrl ? { ...attachment, previewUrl } : attachment;
586 }),
587 }
588 : {}),
589 runId: item.runId,
590 streaming: item.type === "assistant_message" && item.streaming,
591 ...(item.type === "user_message"
592 ? {
593 createdBy: item.createdBy,
594 creationSource: item.creationSource,
595 ...(item.senderThreadId !== undefined ? { senderThreadId: item.senderThreadId } : {}),
596 ...(item.linkedSender !== undefined ? { linkedSender: item.linkedSender } : {}),
597 ...(item.idleCompaction !== undefined ? { idleCompaction: item.idleCompaction } : {}),
598 ...(item.scheduledTaskId !== undefined
599 ? { scheduledTaskId: item.scheduledTaskId }
600 : {}),
601 }
602 : {}),
603 createdAt,
604 updatedAt: DateTime.formatIso(item.updatedAt),
605 ...(item.type === "user_message" ? { inputIntent: item.inputIntent } : {}),
606 };
607 committedMessageIds.add(message.id);
608 entries.push({
609 id: message.id,
610 kind: "message",
611 createdAt,
612 message,
613 projectedItem: row,
614 ...attemptMetadata,
615 });
616 continue;
617 }
618​
619 if (item.type === "proposed_plan") {
620 const plan = planById.get(item.planId);
621 const proposedPlan = {
622 id: item.planId,
623 runId: item.runId,
624 planMarkdown: item.markdown,
625 status: plan?.kind === "proposed_plan" ? plan.status : ("active" as const),
626 createdAt,
627 updatedAt: DateTime.formatIso(item.updatedAt),
628 };
629 entries.push({
630 id: item.id,
631 kind: "proposed-plan",
632 createdAt,
633 proposedPlan,
634 ...attemptMetadata,
635 });
636 continue;
637 }
638​
639 const htmlRender =
640 item.type === "dynamic_tool" && item.status === "completed"
641 ? htmlRenderFromToolItem(item)
642 : undefined;
643 if (htmlRender !== undefined) {
644 entries.push({
645 id: item.id,
646 kind: "html-render",
647 createdAt,
648 runId: item.runId,
649 htmlRender,
650 ...attemptMetadata,
651 });
652 continue;
653 }
654​
655 if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
656 entries.push({
657 id: item.id,
658 kind: "event",
659 createdAt,
660 projectedItem: row,
661 ...attemptMetadata,
662 });
663 continue;
664 }
665​
666 entries.push({
667 id: item.id,
668 kind: "work",
669 createdAt,
670 entry: projectedWorkEntry(row),
671 ...attemptMetadata,
672 });
673​
674 const mcpApp = item.type === "dynamic_tool" ? mcpAppFromToolItem(item) : undefined;
675 if (mcpApp !== undefined) {
676 entries.push({
677 id: `mcp-app:${item.id}`,
678 kind: "mcp-app",
679 createdAt,
680 runId: item.runId,
681 sourceThreadId: row.sourceThreadId,
682 itemId: row.sourceItemId,
683 revision: turnItemDetailRevision(item),
684 mcpApp,
685 ...attemptMetadata,
686 });
687 }
688 }
689​
690 const retainedMessageIds = new Set([
691 ...committedMessageIds,
692 ...foldedAnswerRunIdByMessageId.keys(),
693 ]);
694 for (const message of input.anchoredMessages ?? []) {
695 if (retainedMessageIds.has(message.id)) continue;
696 retainedMessageIds.add(message.id);
697 const entry: TimelineEntry = {
698 id: message.id,
699 kind: "message",
700 createdAt: message.createdAt,
701 message,
702 };
703 const insertionIndex = entries.findIndex(
704 (candidate) => candidate.createdAt > message.createdAt,
705 );
706 if (insertionIndex === -1) {
707 entries.push(entry);
708 } else {
709 entries.splice(insertionIndex, 0, entry);
710 }
711 }
712​
713 for (const message of input.optimisticMessages) {
714 if (message.inputIntent !== "queued_turn" && !retainedMessageIds.has(message.id)) {
715 retainedMessageIds.add(message.id);
716 entries.push({
717 id: message.id,
718 kind: "message",
719 createdAt: message.createdAt,
720 message,
721 });
722 }
723 }
724​
725 return entries;
727​
728type AttachmentResource = Extract<AssetResource, { readonly _tag: "attachment" }>;
729const EMPTY_IMAGE_RESOURCES = Object.freeze<ReadonlyArray<AttachmentResource>>([]);
730​
731export function selectMessageImageResources(
732 attachments: ChatMessage["attachments"],
733): ReadonlyArray<AttachmentResource> {
734 const attachmentIds = new Set<string>();
735 for (const attachment of attachments ?? []) {
736 if (!isImageAttachment(attachment)) continue;
737 const previewUrl = attachment.previewUrl;
738 if (previewUrl?.startsWith("blob:") || previewUrl?.startsWith("data:")) continue;
739 attachmentIds.add(attachment.id);
740 }
741 return attachmentIds.size === 0
742 ? EMPTY_IMAGE_RESOURCES
743 : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId }));
745​
746export function selectHandoffImageResources(
747 messages: ReadonlyArray<Pick<ChatMessage, "id" | "role" | "attachments">> | undefined,
748 handoffs: Readonly<Record<string, ReadonlyArray<string>>>,
749): ReadonlyArray<AttachmentResource> {
750 if (Object.keys(handoffs).length === 0) return EMPTY_IMAGE_RESOURCES;
751 const attachmentIds = new Set<string>();
752 for (const message of messages ?? []) {
753 if (message.role !== "user" || !handoffs[message.id]?.length) continue;
754 for (const attachment of message.attachments ?? []) {
755 if (isImageAttachment(attachment)) attachmentIds.add(attachment.id);
756 }
757 }
758 return attachmentIds.size === 0
759 ? EMPTY_IMAGE_RESOURCES
760 : Array.from(attachmentIds, (attachmentId) => ({ _tag: "attachment", attachmentId }));
762​
763export function createMessageAttachmentPreviewProjector() {
764 const attachmentsBySource = new WeakMap<
765 ReadonlyArray<ChatAttachment>,
766 ReadonlyArray<ChatAttachment>
767 >();
768 const messagesBySource = new WeakMap<ChatMessage, ChatMessage>();
769 return (
770 message: ChatMessage,
771 previewUrlFor: (attachment: ChatAttachment) => string | undefined,
772 ): ChatMessage => {
773 const source = message.attachments;
774 if (!source || source.length === 0) return message;
775 const previous = attachmentsBySource.get(source) ?? source;
776 let changed: ChatAttachment[] | undefined;
777 let hasOverrides = false;
778 for (const [index, attachment] of source.entries()) {
779 const previewUrl = previewUrlFor(attachment);
780 const sourceUrl = "previewUrl" in attachment ? attachment.previewUrl : undefined;
781 const previousAttachment = previous[index]!;
782 const previousUrl =
783 "previewUrl" in previousAttachment ? previousAttachment.previewUrl : undefined;
784 const next =
785 !previewUrl || previewUrl === sourceUrl
786 ? attachment
787 : previewUrl === previousUrl
788 ? previousAttachment
789 : { ...attachment, previewUrl };
790 hasOverrides ||= next !== attachment;
791 if (next !== previousAttachment) {
792 changed ??= previous.slice();
793 changed[index] = next;
794 }
795 }
796 const attachments = hasOverrides ? (changed ?? previous) : source;
797 attachmentsBySource.set(source, attachments);
798 if (attachments === source) {
799 messagesBySource.delete(message);
800 return message;
801 }
802 const previousMessage = messagesBySource.get(message);
803 if (previousMessage?.attachments === attachments) return previousMessage;
804 const result = { ...message, attachments };
805 messagesBySource.set(message, result);
806 return result;
807 };
809​
810export function isStreamingMessageTextUpdate(previous: ChatMessage, next: ChatMessage): boolean {
811 if (
812 previous.role !== "assistant" ||
813 next.role !== "assistant" ||
814 !previous.streaming ||
815 !next.streaming
816 ) {
817 return false;
818 }
819 const { text: _previousText, updatedAt: _previousUpdatedAt, ...previousMetadata } = previous;
820 const { text: _nextText, updatedAt: _nextUpdatedAt, ...nextMetadata } = next;
821 return shallow(previousMetadata, nextMetadata);
823​
824function streamedTextMetadata(item: OrchestrationV2TurnItem) {
825 if (item.type === "proposed_plan" && item.streaming) {
826 const { markdown: _markdown, updatedAt: _updatedAt, ...metadata } = item;
827 return metadata;
828 }
829 if ((item.type === "assistant_message" || item.type === "reasoning") && item.streaming) {
830 const { text: _text, updatedAt: _updatedAt, ...metadata } = item;
831 return metadata;
832 }
833 return null;
835​
836export function isStreamingTurnItemTextUpdate(
837 previous: OrchestrationV2ProjectedTurnItem,
838 next: OrchestrationV2ProjectedTurnItem,
839): boolean {
840 const { item: previousItem, ...previousSource } = previous;
841 const { item: nextItem, ...nextSource } = next;
842 const previousMetadata = streamedTextMetadata(previousItem);
843 const nextMetadata = streamedTextMetadata(nextItem);
844 if (
845 previousMetadata === null ||
846 nextMetadata === null ||
847 !shallow(previousSource, nextSource) ||
848 projectedItemCreatedAt(previous) !== projectedItemCreatedAt(next)
849 ) {
850 return false;
851 }
852 // Wire decoding recreates timestamps and attachments; compare only the changed item's metadata.
853 return Equal.equals(previousMetadata, nextMetadata);
855​
856/**
857 * A changed tool or work item maps to exactly one entry with the item's id.
858 * Messages and question answers take part in cross-entry dedupe, and anchored
859 * messages are placed by createdAt, so those changes rebuild.
860 */
861function canRederiveInPlace(
862 input: TimelineEntriesInput,
863 previousItem: OrchestrationV2ProjectedTurnItem,
864 item: OrchestrationV2ProjectedTurnItem,
865): boolean {
866 return (
867 item.item.id === previousItem.item.id &&
868 item.item.type === previousItem.item.type &&
869 item.item.type !== "user_message" &&
870 item.item.type !== "assistant_message" &&
871 item.item.type !== "user_input_request" &&
872 // An app call projects two entries; in-place reuse replaces one.
873 (previousItem.item.type !== "dynamic_tool" ||
874 mcpAppFromToolItem(previousItem.item) === undefined) &&
875 ((input.anchoredMessages?.length ?? 0) === 0 ||
876 projectedItemCreatedAt(item) === projectedItemCreatedAt(previousItem))
877 );
879​
880/** Entries read only each plan's status; streaming plans rebuild artifacts with every delta. */
881function samePlanStatuses(
882 next: TimelineEntriesInput["plans"],
883 previous: TimelineEntriesInput["plans"],
884): boolean {
885 if (next === previous) return true;
886 if (next === undefined || previous === undefined || next.length !== previous.length) return false;
887 return next.every((plan, index) => {
888 const before = previous[index]!;
889 return plan.id === before.id && plan.kind === before.kind && plan.status === before.status;
890 });
892​
893function reuseTimelineEntries(
894 input: TimelineEntriesInput,
895 previous: TimelineEntriesProjection,
896): TimelineEntry[] | null {
897 const before = previous.input;
898 if (
899 input.visibleTurnItems.length < before.visibleTurnItems.length ||
900 !shallow(input.optimisticMessages, before.optimisticMessages) ||
901 !shallow(input.anchoredMessages, before.anchoredMessages) ||
902 !shallow(input.attachmentUrlById, before.attachmentUrlById) ||
903 !shallow(input.attempts, before.attempts) ||
904 !shallow(input.nodes, before.nodes) ||
905 !samePlanStatuses(input.plans, before.plans)
906 ) {
907 return null;
908 }
909 const appended = input.visibleTurnItems.length > before.visibleTurnItems.length;
910 // Keep full projection until committed items interleave and deduplicate anchored/optimistic messages.
911 if (
912 appended &&
913 (input.optimisticMessages.length > 0 || (input.anchoredMessages?.length ?? 0) > 0)
914 ) {
915 return null;
916 }
917 // Answer rows can replace a message already present in the retained prefix.
918 if (
919 appended &&
920 input.visibleTurnItems
921 .slice(before.visibleTurnItems.length)
922 .some(
923 ({ item }) =>
924 (item.type === "user_input_request" && item.questionAnswer !== undefined) ||
925 (item.type === "user_message" && item.messageId.startsWith("async-answer:")),
926 )
927 )
928 return null;
929 const replacements = new Map<
930 OrchestrationV2ProjectedTurnItem,
931 OrchestrationV2ProjectedTurnItem
932 >();
933 const changedItems: OrchestrationV2ProjectedTurnItem[] = [];
934 for (let index = 0; index < before.visibleTurnItems.length; index += 1) {
935 const previousItem = before.visibleTurnItems[index]!;
936 const item = input.visibleTurnItems[index]!;
937 if (item === previousItem) continue;
938 // Reasoning and plan text re-derive below; only assistant text patches the message.
939 if (
940 item.item.type === "assistant_message" &&
941 isStreamingTurnItemTextUpdate(previousItem, item)
942 ) {
943 replacements.set(previousItem, item);
944 continue;
945 }
946 if (!canRederiveInPlace(input, previousItem, item)) return null;
947 changedItems.push(item);
948 }
949 // One batch keeps the attempt/node/plan lookups to a single build.
950 const rederived =
951 changedItems.length === 0
952 ? []
953 : deriveTimelineEntriesFromVisibleTurnItems({
954 ...input,
955 visibleTurnItems: changedItems,
956 optimisticMessages: [],
957 anchoredMessages: [],
958 });
959 if (
960 rederived.length !== changedItems.length ||
961 rederived.some((entry, index) => entry.id !== changedItems[index]!.item.id)
962 ) {
963 return null;
964 }
965 const rederivedEntries = new Map(rederived.map((entry) => [entry.id, entry]));
966 if (replacements.size === 0 && rederivedEntries.size === 0 && !appended) {
967 return previous.entries;
968 }
969 let rederivedCount = 0;
970 const entries = previous.entries.map((entry): TimelineEntry => {
971 const rederivedEntry = rederivedEntries.get(entry.id);
972 if (rederivedEntry !== undefined) {
973 rederivedCount += 1;
974 return rederivedEntry;
975 }
976 const row =
977 entry.kind === "message" && entry.projectedItem !== undefined
978 ? replacements.get(entry.projectedItem)
979 : undefined;
980 if (entry.kind !== "message" || row?.item.type !== "assistant_message") return entry;
981 return {
982 ...entry,
983 projectedItem: row,
984 message: {
985 ...entry.message,
986 text: row.item.text,
987 updatedAt: DateTime.formatIso(row.item.updatedAt),
988 },
989 };
990 });
991 if (rederivedCount !== rederivedEntries.size) return null;
992 if (appended) {
993 entries.push(
994 ...deriveTimelineEntriesFromVisibleTurnItems({
995 ...input,
996 visibleTurnItems: input.visibleTurnItems.slice(before.visibleTurnItems.length),
997 }),
998 );
999 }
1000 return entries;
1002​
1003export function deriveTimelineEntriesFromVisibleTurnItemsWithState(
1004 input: TimelineEntriesInput,
1005 previous: TimelineEntriesProjection | null = null,
1006): TimelineEntriesProjection {
1007 const reused = previous === null ? null : reuseTimelineEntries(input, previous);
1008 if (reused !== null) return { input, entries: reused };
1009 const entries = deriveTimelineEntriesFromVisibleTurnItems(input);
1010 if (previous === null || !shallow(input.attachmentUrlById, previous.input.attachmentUrlById)) {
1011 return { input, entries };
1013 const previousMessages = new Map(
1014 previous.entries.flatMap((entry) =>
1015 entry.kind === "message" ? [[entry.id, entry] as const] : [],
1016 ),
1017 );
1018 return {
1019 input,
1020 entries: entries.map((entry) => {
1021 if (entry.kind !== "message" || entry.projectedItem === undefined) return entry;
1022 const before = previousMessages.get(entry.id);
1023 if (before?.projectedItem !== entry.projectedItem) return entry;
1024 return before.attempt === entry.attempt ? before : { ...entry, message: before.message };
1025 }),
1026 };
1028​
1029// Run ordinals keep increasing after a rewind; only visible local runs define predecessors.
1030export function deriveRevertTurnCountByUserMessageId(input: {
1031 readonly timelineEntries: ReadonlyArray<TimelineEntry>;
1032 readonly checkpoints: ReadonlyArray<ThreadCheckpointSummary>;
1033 readonly threadStartCheckpointAvailable: boolean;
1034 readonly availableRevertTurnCounts?: ReadonlySet<number> | undefined;
1035 readonly unsettledRunId?: RunId | null;
1036}): Map<ChatMessage["id"], number> {
1037 const turnCountByRunId = new Map<RunId, number>();
1038 for (const checkpoint of input.checkpoints) {
1039 if (isRewindableCheckpoint(checkpoint)) {
1040 turnCountByRunId.set(checkpoint.runId, checkpoint.checkpointTurnCount);
1043 const targetByRunId = new Map<RunId, number | undefined>();
1044 const byUserMessageId = new Map<ChatMessage["id"], number>();
1045 let previousRunId: RunId | null = null;
1046 for (const entry of input.timelineEntries) {
1047 // Inherited history belongs to the source thread, not our checkpoint scope.
1048 if (entry.kind !== "message" && entry.kind !== "work") continue;
1049 const projectedItem =
1050 entry.kind === "message" ? entry.projectedItem : entry.entry.projectedItem;
1051 if (projectedItem?.visibility === "inherited") continue;
1052 const runId =
1053 entry.kind === "message"
1054 ? entry.message.runId
1055 : entry.entry.itemType === "notification"
1056 ? entry.entry.runId
1057 : null;
1058 if (runId == null) continue;
1059 if (!targetByRunId.has(runId)) {
1060 targetByRunId.set(
1061 runId,
1062 previousRunId === null
1063 ? input.threadStartCheckpointAvailable
1064 ? 0
1065 : undefined
1066 : turnCountByRunId.get(previousRunId),
1067 );
1068 previousRunId = runId;
1070 if (entry.kind !== "message") continue;
1071 const { message } = entry;
1072 if (message.role !== "user" || message.runId === input.unsettledRunId) continue;
1073 if (message.inputIntent !== "turn_start" && message.inputIntent !== "queued_turn") continue;
1074 const target = targetByRunId.get(runId);
1075 if (
1076 target !== undefined &&
1077 (input.availableRevertTurnCounts === undefined || input.availableRevertTurnCounts.has(target))
1079 byUserMessageId.set(message.id, target);
1081 return byUserMessageId;
1083​
1084// A missing snapshot (no git, or a side chat) still pins a conversation position; only
1085// restoring files needs a ready one.
1086function isRewindableCheckpoint(checkpoint: Pick<OrchestrationV2Checkpoint, "status">) {
1087 return checkpoint.status === "ready" || checkpoint.status === "missing";
1089​
1090export function findRevertCheckpoint(
1091 checkpoints: ReadonlyArray<OrchestrationV2Checkpoint>,
1092 turnCount: number,
1093): OrchestrationV2Checkpoint | undefined {
1094 return checkpoints.findLast(
1095 (checkpoint) =>
1096 isRewindableCheckpoint(checkpoint) &&
1097 (turnCount === 0
1098 ? checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null
1099 : checkpoint.appRunOrdinal === turnCount),
1100 );
1102​
1103export function derivePhase(runtime: ThreadStructureRuntime | null): SessionPhase {
1104 if (runtime === null) return "disconnected";
1105 if (
1106 runtime.status === "preparing" ||
1107 runtime.status === "starting" ||
1108 runtime.status === "queued"
1110 return "connecting";
1111 if (runtime.status === "running" || runtime.status === "waiting") return "running";
1112 return "ready";
1114​
1115/** Preparing and starting runs can be interrupted; Stop need not wait for running. */
1116export function deriveCanInterruptRunningThread(
1117 hasActiveThread: boolean,
1118 runtime: ThreadStructureRuntime | null,
1119): boolean {
1120 return (
1121 hasActiveThread &&
1122 (derivePhase(runtime) === "running" || threadRuntimeHasInterruptibleRun(runtime))
1123 );
1125​
1126export type { TurnDiffSummary };