1import {
2 MessageId,
3 RuntimeRequestId,
4 CheckpointId,
5 CheckpointScopeId,
6 NodeId,
7 PlanId,
8 ProviderInstanceId,
9 ProviderThreadId,
10 RunAttemptId,
11 RunId,
12 ScheduledTaskId,
13 ThreadId,
14 TurnItemId,
15 type OrchestrationV2ProjectedTurnItem,
16 type OrchestrationV2ExecutionNode,
17 type OrchestrationV2RunAttempt,
18 type OrchestrationV2TurnItem,
19} from "@t3tools/contracts";
20import type { ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell";
21import { deriveMessagesTimelineRows } from "./components/chat/MessagesTimeline.logic";
22import * as DateTime from "effect/DateTime";
23import { describe, expect, it } from "vite-plus/test";
24
25import {
26 deriveActivePlanState,
27 deriveCanInterruptRunningThread,
28 deriveTimelineEntriesFromVisibleTurnItems,
29 deriveTimelineEntriesFromVisibleTurnItemsWithState,
30 deriveRevertTurnCountByUserMessageId,
31 derivePhase,
32 findLatestProposedPlan,
33 isLatestRunSettled,
34 selectHandoffImageResources,
35 selectMessageImageResources,
36 createMessageAttachmentPreviewProjector,
37 providerErrorPresentation,
38 type TimelineEntry,
39 workEntryIndicatesToolFailure,
40 workEntryDisplayIndicatesToolFailure,
41 workEntryIndicatesToolSuccess,
42} from "./session-logic";
43import { makeStreamingTimelineFixture, makeThreadProjectionFixture } from "./test-fixtures";
44import type { ChatMessage } from "./types";
45
46describe("V2 session presentation", () => {
47 it("uses run status as the settlement boundary", () => {
48 const runId = RunId.make("run-1");
49 expect(
50 isLatestRunSettled(
51 {
52 runId,
53 status: "completed",
54 startedAt: "2026-06-20T00:00:00.000Z",
55 completedAt: "2026-06-20T00:01:00.000Z",
56 },
57 null,
58 ),
59 ).toBe(true);
60 expect(
61 isLatestRunSettled(
62 { runId, status: "running", startedAt: null, completedAt: null },
63 { status: "running", activeRunId: runId },
64 ),
65 ).toBe(false);
66 expect(
67 isLatestRunSettled(
68 { runId, status: "queued", startedAt: null, completedAt: null },
69 { status: "running", activeRunId: RunId.make("run-active") },
70 ),
71 ).toBe(false);
72 });
73
74 it("offers Stop while a run is preparing/starting, not just once it's running (#13392)", () => {
75 const runtimeWithStatus = (
76 status: ThreadRuntimeSummary["status"],
77 activeRunId: RunId | null = null,
78 ): ThreadRuntimeSummary => ({
79 status,
80 activeRunId,
81 providerInstanceId: ProviderInstanceId.make("claude-default"),
82 providerName: null,
83 lastError: null,
84 updatedAt: "2026-09-29T00:00:00.000Z",
85 });
86 const runId = RunId.make("run-stop-while-starting");
87
88 for (const status of ["preparing", "starting", "running"] as const) {
89 const runtime = runtimeWithStatus(status, runId);
90 expect(deriveCanInterruptRunningThread(true, runtime)).toBe(true);
91 }
92
93 // No active thread: never offer Stop, regardless of run status.
94 expect(deriveCanInterruptRunningThread(false, runtimeWithStatus("running", runId))).toBe(false);
95
96 // Queued with nothing interruptible: the server rejects interrupting a
97 // queued run, so Stop stays hidden.
98 expect(derivePhase(runtimeWithStatus("queued"))).toBe("connecting");
99 expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("queued"))).toBe(false);
100 // Queued behind a run that is still interruptible: Stop targets that run.
101 expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("queued", runId))).toBe(true);
102
103 // Waiting (e.g. on a subagent) is treated as "running" by derivePhase and
104 // keeps offering Stop, unchanged from before.
105 expect(derivePhase(runtimeWithStatus("waiting"))).toBe("running");
106 expect(deriveCanInterruptRunningThread(true, runtimeWithStatus("waiting"))).toBe(true);
107
108 // No runtime at all: nothing to interrupt.
109 expect(deriveCanInterruptRunningThread(true, null)).toBe(false);
110 });
111
112 it("labels provider retry progress, delay, recovery, and exhaustion", () => {
113 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
114 const retryItem = {
115 id: TurnItemId.make("item-provider-retry"),
116 threadId: ThreadId.make("thread-provider-retry"),
117 runId: RunId.make("run-provider-retry"),
118 nodeId: null,
119 providerThreadId: null,
120 providerTurnId: null,
121 nativeItemRef: null,
122 parentItemId: null,
123 ordinal: 1,
124 status: "running" as const,
125 title: "Provider retry",
126 startedAt: now,
127 completedAt: null,
128 updatedAt: now,
129 type: "error" as const,
130 failure: {
131 class: "provider_error" as const,
132 message: "Claude API overloaded.",
133 code: "api_error_529",
134 retryable: true,
135 },
136 retry: {
137 attempt: 2,
138 maxAttempts: 10,
139 retryDelayMs: 1_500,
140 },
141 } satisfies Extract<OrchestrationV2TurnItem, { readonly type: "error" }>;
142
143 expect(
144 providerErrorPresentation({
145 ...retryItem,
146 status: "failed",
147 failure: { ...retryItem.failure, class: "usage_limit" },
148 }),
149 ).toMatchObject({ label: "Usage limit reached after 2/10 retries" });
150 const recoveredLimit = {
151 ...retryItem,
152 status: "completed" as const,
153 completedAt: now,
154 failure: { ...retryItem.failure, class: "usage_limit" as const },
155 };
156 const [recoveredEntry] = deriveTimelineEntriesFromVisibleTurnItems({
157 visibleTurnItems: [
158 {
159 item: recoveredLimit,
160 position: 0,
161 visibility: "local",
162 sourceThreadId: recoveredLimit.threadId,
163 sourceItemId: recoveredLimit.id,
164 },
165 ],
166 optimisticMessages: [],
167 });
168 if (recoveredEntry?.kind !== "work") throw new Error("Expected recovered provider work");
169 expect(recoveredEntry.entry.label).toBe("Provider recovered (2/10 retries)");
170 expect(recoveredEntry.entry.sourceActivityKind).not.toBe("runtime.warning");
171 expect(workEntryDisplayIndicatesToolFailure(recoveredEntry.entry)).toBe(false);
172 expect(providerErrorPresentation(retryItem)).toEqual({
173 label: "Retrying provider (2/10)",
174 detail: "Claude API overloaded. Retrying in 1.5s.",
175 });
176 expect(
177 providerErrorPresentation({
178 ...retryItem,
179 status: "completed",
180 completedAt: now,
181 }),
182 ).toMatchObject({ label: "Provider recovered (2/10 retries)" });
183 expect(
184 providerErrorPresentation({
185 ...retryItem,
186 status: "failed",
187 retry: { ...retryItem.retry, attempt: 10 },
188 completedAt: now,
189 }),
190 ).toMatchObject({ label: "Provider error after 10/10 retries" });
191 });
192
193 it("selects the latest proposed plan for a run", () => {
194 const runId = RunId.make("run-1");
195 const planId = PlanId.make("plan-1");
196 const nodeId = NodeId.make("node-plan");
197 const now = DateTime.makeUnsafe("2026-06-20T00:00:01.000Z");
198 const baseProjection = makeThreadProjectionFixture();
199 const plan = findLatestProposedPlan(
200 {
201 ...baseProjection,
202 plans: [
203 {
204 id: planId,
205 threadId: baseProjection.thread.id,
206 nodeId,
207 kind: "proposed_plan" as const,
208 markdown: "Plan",
209 status: "active" as const,
210 runId,
211 },
212 ],
213 turnItems: [
214 {
215 id: TurnItemId.make("item-plan"),
216 threadId: baseProjection.thread.id,
217 nodeId,
218 providerThreadId: null,
219 providerTurnId: null,
220 nativeItemRef: null,
221 parentItemId: null,
222 ordinal: 0,
223 status: "completed" as const,
224 title: null,
225 startedAt: now,
226 completedAt: now,
227 updatedAt: now,
228 type: "proposed_plan" as const,
229 planId,
230 markdown: "Plan",
231 streaming: false,
232 runId,
233 },
234 ],
235 updatedAt: now,
236 },
237 runId,
238 );
239 expect(plan?.planMarkdown).toBe("Plan");
240 });
241
242 it("assigns run rollback to the turn-start message instead of a later steer", () => {
243 const runId = RunId.make("run-steered");
244 const turnStartMessageId = MessageId.make("message-turn-start");
245 const steerMessageId = MessageId.make("message-steer");
246 const assistantMessageId = MessageId.make("message-assistant");
247 const messages: ChatMessage[] = [
248 {
249 id: turnStartMessageId,
250 role: "user",
251 text: "Start",
252 runId,
253 inputIntent: "turn_start",
254 streaming: false,
255 createdAt: "2026-06-20T00:00:00.000Z",
256 updatedAt: "2026-06-20T00:00:00.000Z",
257 },
258 {
259 id: steerMessageId,
260 role: "user",
261 text: "Steer",
262 runId,
263 inputIntent: "steer",
264 streaming: false,
265 createdAt: "2026-06-20T00:00:01.000Z",
266 updatedAt: "2026-06-20T00:00:01.000Z",
267 },
268 {
269 id: assistantMessageId,
270 role: "assistant",
271 text: "Done",
272 runId,
273 streaming: false,
274 createdAt: "2026-06-20T00:00:02.000Z",
275 updatedAt: "2026-06-20T00:00:02.000Z",
276 },
277 ];
278 const timelineEntries: TimelineEntry[] = messages.map((message): TimelineEntry => ({
279 id: message.id,
280 kind: "message",
281 createdAt: message.createdAt,
282 message,
283 }));
284
285 const targets = deriveRevertTurnCountByUserMessageId({
286 timelineEntries,
287 checkpoints: [
288 {
289 runId,
290 checkpointTurnCount: 1,
291 checkpointRef: "checkpoint-run-1" as never,
292 status: "ready",
293 files: [],
294 assistantMessageId,
295 completedAt: "2026-06-20T00:00:03.000Z",
296 },
297 ],
298 });
299
300 expect([...targets]).toEqual([[turnStartMessageId, 0]]);
301 expect(targets.has(steerMessageId)).toBe(false);
302 });
303
304 it("uses visible turn item order and keeps provider errors in the work log", () => {
305 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
306 const threadId = ThreadId.make("thread-visible");
307 const runId = RunId.make("run-visible");
308 const base = (id: string, ordinal: number) => ({
309 id: TurnItemId.make(id),
310 threadId,
311 runId,
312 nodeId: null,
313 providerThreadId: null,
314 providerTurnId: null,
315 nativeItemRef: null,
316 parentItemId: null,
317 ordinal,
318 status: "completed" as const,
319 title: null,
320 startedAt: now,
321 completedAt: now,
322 updatedAt: now,
323 });
324 const userItem = {
325 ...base("item-user", 0),
326 type: "user_message" as const,
327 messageId: MessageId.make("message-user"),
328 inputIntent: "turn_start" as const,
329 text: "Start",
330 attachments: [],
331 createdBy: "user" as const,
332 creationSource: "web" as const,
333 } satisfies OrchestrationV2TurnItem;
334 const requestItem = {
335 ...base("item-interrupt-request", 1),
336 type: "run_interrupt_request" as const,
337 message: "Stopping",
338 } satisfies OrchestrationV2TurnItem;
339 const commandItem = {
340 ...base("item-command", 2),
341 type: "command_execution" as const,
342 input: "sleep 1",
343 output: "done",
344 exitCode: 0,
345 } satisfies OrchestrationV2TurnItem;
346 const resultItem = {
347 ...base("item-interrupt-result", 3),
348 type: "run_interrupt_result" as const,
349 message: "Stopped",
350 } satisfies OrchestrationV2TurnItem;
351 const todoItem = {
352 ...base("item-todo", 4),
353 type: "todo_list" as const,
354 planId: PlanId.make("plan-visible"),
355 explanation: "Keep task detail in the Tasks panel",
356 steps: [
357 { id: "step-1", text: "First", status: "completed" as const },
358 { id: "step-2", text: "Second", status: "pending" as const },
359 ],
360 } satisfies OrchestrationV2TurnItem;
361 const errorItem = {
362 ...base("item-error", 5),
363 status: "failed" as const,
364 type: "error" as const,
365 failure: {
366 class: "validation_error" as const,
367 message: "Invalid reasoning effort.",
368 code: "invalid_request",
369 retryable: false,
370 },
371 } satisfies OrchestrationV2TurnItem;
372 const threadCreatedItem = {
373 ...base("item-thread-created", 6),
374 type: "thread_created" as const,
375 title: "Follow-up thread",
376 targetThreadId: ThreadId.make("thread-follow-up"),
377 targetRunId: RunId.make("run-follow-up"),
378 targetProviderInstanceId: ProviderInstanceId.make("claude-default"),
379 targetModel: "claude-sonnet-4-6",
380 } satisfies OrchestrationV2TurnItem;
381 const workspacePreparationItem = {
382 ...base("item-workspace-preparation", 7),
383 type: "command_execution" as const,
384 title: "Workspace ready",
385 input: "Preparing workspace",
386 output: "Workspace preparation completed.",
387 exitCode: 0,
388 } satisfies OrchestrationV2TurnItem;
389 const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [
390 userItem,
391 requestItem,
392 commandItem,
393 resultItem,
394 todoItem,
395 errorItem,
396 threadCreatedItem,
397 workspacePreparationItem,
398 ].map((item, position) => ({
399 position,
400 visibility: "local" as const,
401 sourceThreadId: threadId,
402 sourceItemId: item.id,
403 item,
404 }));
405
406 const entries = deriveTimelineEntriesFromVisibleTurnItems({
407 visibleTurnItems,
408 optimisticMessages: [],
409 });
410
411 expect(entries.map((entry) => [entry.kind, entry.id])).toEqual([
412 ["message", userItem.messageId],
413 ["event", requestItem.id],
414 ["work", commandItem.id],
415 ["event", resultItem.id],
416 ["work", errorItem.id],
417 ["work", threadCreatedItem.id],
418 ]);
419 const commandEntry = entries[2];
420 const userEntry = entries[0];
421 expect(userEntry?.kind).toBe("message");
422 if (userEntry?.kind === "message") {
423 expect(userEntry.projectedItem).toBe(visibleTurnItems[0]);
424 expect(userEntry.message.inputIntent).toBe("turn_start");
425 expect(userEntry.message.createdBy).toBe("user");
426 expect(userEntry.message.creationSource).toBe("web");
427 }
428 expect(commandEntry?.kind).toBe("work");
429 if (commandEntry?.kind === "work") {
430 expect(commandEntry.entry.projectedItem).toBe(visibleTurnItems[2]);
431 expect(commandEntry.entry.structuredPayload).toBe(commandItem);
432 expect(commandEntry.entry.command).toBe(commandItem.input);
433 expect(commandEntry.entry.detail).toBeUndefined();
434 }
435 const errorEntry = entries[4];
436 expect(errorEntry?.kind).toBe("work");
437 if (errorEntry?.kind === "work") {
438 expect(errorEntry.entry.projectedItem).toBe(visibleTurnItems[5]);
439 expect(errorEntry.entry.label).toBe("Provider error");
440 expect(errorEntry.entry.detail).toBe("Invalid reasoning effort.");
441 expect(errorEntry.entry.tone).toBe("info");
442 expect(errorEntry.entry.toolLifecycleStatus).toBe("failed");
443 }
444 const threadCreatedEntry = entries[5];
445 expect(threadCreatedEntry?.kind).toBe("work");
446 if (threadCreatedEntry?.kind === "work") {
447 expect(threadCreatedEntry.entry.projectedItem?.item.type).toBe("thread_created");
448 }
449 });
450
451 it.each(["pending", "running", "completed"] as const)(
452 "keeps %s task progress available to the composer and out of the timeline",
453 (stepStatus) => {
454 const projection = makeThreadProjectionFixture();
455 const now = DateTime.makeUnsafe("2026-09-04T00:00:00.000Z");
456 const runId = RunId.make("run-tasks");
457 const nodeId = NodeId.make("node-tasks");
458 const planId = PlanId.make("plan-tasks");
459 const steps = [{ id: "step-1", text: "Verify the change", status: stepStatus }];
460 const item = {
461 id: TurnItemId.make("item-tasks"),
462 threadId: projection.thread.id,
463 runId,
464 nodeId,
465 providerThreadId: null,
466 providerTurnId: null,
467 nativeItemRef: null,
468 parentItemId: null,
469 ordinal: 0,
470 status: "completed" as const,
471 title: null,
472 startedAt: now,
473 completedAt: now,
474 updatedAt: now,
475 type: "todo_list" as const,
476 planId,
477 steps,
478 } satisfies OrchestrationV2TurnItem;
479 const plans = [
480 {
481 id: planId,
482 threadId: projection.thread.id,
483 runId,
484 nodeId,
485 kind: "todo_list" as const,
486 status: "active" as const,
487 steps,
488 },
489 ];
490
491 expect(
492 deriveTimelineEntriesFromVisibleTurnItems({
493 visibleTurnItems: [
494 {
495 position: 0,
496 visibility: "local",
497 sourceThreadId: projection.thread.id,
498 sourceItemId: item.id,
499 item,
500 },
501 ],
502 optimisticMessages: [],
503 plans,
504 }),
505 ).toEqual([]);
506 expect(
507 deriveActivePlanState({ ...projection, plans, turnItems: [item] }, runId),
508 ).toMatchObject({
509 runId,
510 steps: [
511 {
512 step: "Verify the change",
513 status: stepStatus === "running" ? "inProgress" : stepStatus,
514 },
515 ],
516 });
517 },
518 );
519
520 it("preserves independently derived durations for repeated plan-step labels", () => {
521 const projection = makeThreadProjectionFixture();
522 const runId = RunId.make("run-timed-tasks");
523 const planId = PlanId.make("plan-timed-tasks");
524 const plan = {
525 id: planId,
526 threadId: projection.thread.id,
527 runId,
528 nodeId: NodeId.make("node-timed-tasks"),
529 kind: "todo_list" as const,
530 status: "active" as const,
531 steps: [
532 { id: "verify-a", text: "Verify", status: "completed" as const, durationMs: 3_000 },
533 { id: "verify-b", text: "Verify", status: "completed" as const, durationMs: 4_000 },
534 { id: "report", text: "Report", status: "pending" as const },
535 ],
536 };
537
538 expect(deriveActivePlanState({ ...projection, plans: [plan] }, runId)?.steps).toEqual([
539 { step: "Verify", status: "completed", durationMs: 3_000 },
540 { step: "Verify", status: "completed", durationMs: 4_000 },
541 { step: "Report", status: "pending" },
542 ]);
543 });
544
545 it("keeps failed tool items tool-toned so groups still summarize", () => {
546 const failedCommand = {
547 id: TurnItemId.make("item-failed-command"),
548 threadId: ThreadId.make("thread-1"),
549 runId: RunId.make("run-1"),
550 nodeId: null,
551 providerThreadId: null,
552 providerTurnId: null,
553 nativeItemRef: null,
554 parentItemId: null,
555 ordinal: 0,
556 status: "failed" as const,
557 title: null,
558 startedAt: null,
559 completedAt: null,
560 updatedAt: DateTime.nowUnsafe(),
561 type: "command_execution" as const,
562 input: "ssh host true",
563 output: "connection refused",
564 exitCode: 255,
565 } satisfies OrchestrationV2TurnItem;
566 const entries = deriveTimelineEntriesFromVisibleTurnItems({
567 visibleTurnItems: [
568 {
569 position: 0,
570 visibility: "local" as const,
571 sourceThreadId: ThreadId.make("thread-1"),
572 sourceItemId: failedCommand.id,
573 item: failedCommand,
574 } as never,
575 ],
576 optimisticMessages: [],
577 });
578 const entry = entries[0];
579 expect(entry?.kind).toBe("work");
580 if (entry?.kind === "work") {
581 // An exit-code failure is still an ordinary tool row: the failed
582 // lifecycle status carries the marker, and an "error" tone here would
583 // knock the whole group out of the "Ran N commands" summary.
584 expect(entry.entry.tone).toBe("tool");
585 expect(entry.entry.toolLifecycleStatus).toBe("failed");
586 }
587 });
588
589 it("waits for a dispatched turn item before adding queued input to the timeline", () => {
590 const projection = makeThreadProjectionFixture();
591 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
592 const runId = RunId.make("run-dispatched-queued");
593 const messageId = MessageId.make("message-dispatched-queued");
594 const optimisticMessage = {
595 id: messageId,
596 role: "user" as const,
597 text: "Queued input",
598 runId: null,
599 inputIntent: "queued_turn" as const,
600 streaming: false,
601 createdAt: DateTime.formatIso(now),
602 updatedAt: DateTime.formatIso(now),
603 };
604
605 expect(
606 deriveTimelineEntriesFromVisibleTurnItems({
607 visibleTurnItems: [],
608 optimisticMessages: [optimisticMessage],
609 }),
610 ).toEqual([]);
611
612 const dispatchedItem = {
613 id: TurnItemId.make("item-dispatched-queued"),
614 threadId: projection.thread.id,
615 runId,
616 nodeId: null,
617 providerThreadId: null,
618 providerTurnId: null,
619 nativeItemRef: null,
620 parentItemId: null,
621 ordinal: 200,
622 status: "completed" as const,
623 title: null,
624 startedAt: now,
625 completedAt: now,
626 updatedAt: now,
627 type: "user_message" as const,
628 messageId,
629 inputIntent: "turn_start" as const,
630 text: "Queued input",
631 attachments: [],
632 createdBy: "agent" as const,
633 creationSource: "mcp" as const,
634 scheduledTaskId: ScheduledTaskId.make("task-queued"),
635 senderThreadId: ThreadId.make("thread-agent-sender"),
636 } satisfies OrchestrationV2TurnItem;
637 const promotedEntries = deriveTimelineEntriesFromVisibleTurnItems({
638 visibleTurnItems: [
639 {
640 position: 0,
641 visibility: "local",
642 sourceThreadId: projection.thread.id,
643 sourceItemId: dispatchedItem.id,
644 item: dispatchedItem,
645 },
646 ],
647 optimisticMessages: [optimisticMessage],
648 });
649 expect(promotedEntries.map((entry) => entry.id)).toEqual([messageId]);
650 expect(promotedEntries[0]?.kind).toBe("message");
651 if (promotedEntries[0]?.kind === "message") {
652 expect(promotedEntries[0].message.inputIntent).toBe("turn_start");
653 expect(promotedEntries[0].message.scheduledTaskId).toBe("task-queued");
654 expect(promotedEntries[0].message.senderThreadId).toBe("thread-agent-sender");
655 }
656 });
657
658 it("anchors feedback before later committed turns without reordering canonical history", () => {
659 const threadId = ThreadId.make("thread-feedback-order");
660 const messageItem = (input: {
661 readonly id: string;
662 readonly role: "user" | "assistant";
663 readonly createdAt: string;
664 readonly ordinal: number;
665 }): OrchestrationV2TurnItem => {
666 const timestamp = DateTime.makeUnsafe(input.createdAt);
667 const common = {
668 id: TurnItemId.make(`item-${input.id}`),
669 threadId,
670 runId: RunId.make(`run-${input.ordinal}`),
671 nodeId: null,
672 providerThreadId: null,
673 providerTurnId: null,
674 nativeItemRef: null,
675 parentItemId: null,
676 ordinal: input.ordinal,
677 status: "completed" as const,
678 title: null,
679 startedAt: timestamp,
680 completedAt: timestamp,
681 updatedAt: timestamp,
682 messageId: MessageId.make(input.id),
683 text: input.id,
684 };
685 return input.role === "user"
686 ? {
687 ...common,
688 type: "user_message",
689 inputIntent: "turn_start",
690 attachments: [],
691 createdBy: "user",
692 creationSource: "web",
693 }
694 : { ...common, type: "assistant_message", streaming: false };
695 };
696 const canonicalItems = [
697 messageItem({ id: "old-user", role: "user", createdAt: "2026-08-29T00:00:01Z", ordinal: 1 }),
698 messageItem({
699 id: "old-assistant",
700 role: "assistant",
701 createdAt: "2026-08-29T00:00:02Z",
702 ordinal: 2,
703 }),
704 messageItem({
705 id: "later-user",
706 role: "user",
707 createdAt: "2026-08-29T00:00:05Z",
708 ordinal: 3,
709 }),
710 messageItem({
711 id: "later-assistant",
712 role: "assistant",
713 createdAt: "2026-08-29T00:00:04Z",
714 ordinal: 4,
715 }),
716 ];
717 const visibleTurnItems = canonicalItems.map((item, position) => ({
718 position,
719 visibility: "local" as const,
720 sourceThreadId: threadId,
721 sourceItemId: item.id,
722 item,
723 }));
724 const feedback = (id: string, role: "user" | "assistant"): ChatMessage => ({
725 id: MessageId.make(id),
726 role,
727 text: id,
728 runId: null,
729 streaming: false,
730 createdAt: "2026-08-29T00:00:03Z",
731 updatedAt: "2026-08-29T00:00:03Z",
732 });
733 const entries = deriveTimelineEntriesFromVisibleTurnItems({
734 visibleTurnItems,
735 anchoredMessages: [
736 feedback("feedback-user", "user"),
737 feedback("feedback-assistant", "assistant"),
738 feedback("later-user", "user"),
739 ],
740 optimisticMessages: [
741 { ...feedback("optimistic-user", "user"), createdAt: "2026-08-29T00:00:00Z" },
742 ],
743 });
744
745 expect(entries.map((entry) => entry.id)).toEqual([
746 "old-user",
747 "old-assistant",
748 "feedback-user",
749 "feedback-assistant",
750 "later-user",
751 "later-assistant",
752 "optimistic-user",
753 ]);
754 expect(
755 entries
756 .filter((entry) => entry.id.startsWith("feedback-"))
757 .every((entry) => entry.kind === "message" && entry.projectedItem === undefined),
758 ).toBe(true);
759 });
760
761 it("uses projected plan status and file contents in timeline entries", () => {
762 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
763 const threadId = ThreadId.make("thread-timeline-artifacts");
764 const runId = RunId.make("run-timeline-artifacts");
765 const nodeId = NodeId.make("node-timeline-artifacts");
766 const planId = PlanId.make("plan-timeline-artifacts");
767 const base = {
768 threadId,
769 runId,
770 nodeId,
771 providerThreadId: null,
772 providerTurnId: null,
773 nativeItemRef: null,
774 parentItemId: null,
775 status: "completed" as const,
776 title: null,
777 startedAt: now,
778 completedAt: now,
779 updatedAt: now,
780 };
781 const planItem = {
782 ...base,
783 id: TurnItemId.make("item-proposed-plan"),
784 ordinal: 0,
785 type: "proposed_plan" as const,
786 planId,
787 markdown: "Finished plan",
788 streaming: false,
789 } satisfies OrchestrationV2TurnItem;
790 const fileItem = {
791 ...base,
792 id: TurnItemId.make("item-file-change"),
793 ordinal: 1,
794 type: "file_change" as const,
795 fileName: "src/example.ts",
796 newStr: "export const answer = 42;\n",
797 } satisfies OrchestrationV2TurnItem;
798 const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [
799 planItem,
800 fileItem,
801 ].map((item, position) => ({
802 position,
803 visibility: "local",
804 sourceThreadId: threadId,
805 sourceItemId: item.id,
806 item,
807 }));
808
809 const entries = deriveTimelineEntriesFromVisibleTurnItems({
810 visibleTurnItems,
811 optimisticMessages: [],
812 plans: [
813 {
814 id: planId,
815 threadId,
816 runId,
817 nodeId,
818 kind: "proposed_plan",
819 markdown: "",
820 status: "completed",
821 detailInTurnItem: true,
822 },
823 ],
824 });
825
826 expect(entries[0]?.kind).toBe("proposed-plan");
827 if (entries[0]?.kind === "proposed-plan") {
828 expect(entries[0].proposedPlan.status).toBe("completed");
829 expect(entries[0].proposedPlan.planMarkdown).toBe("Finished plan");
830 }
831 expect(entries[1]?.kind).toBe("work");
832 if (entries[1]?.kind === "work") {
833 expect(entries[1].entry.detail).toBeUndefined();
834 expect(entries[1].entry.changedFiles).toEqual([fileItem.fileName]);
835 }
836 });
837
838 it("resolves attempt identity through V2 execution nodes", () => {
839 const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z");
840 const threadId = ThreadId.make("thread-attempts");
841 const runId = RunId.make("run-steered");
842 const supersededRootNodeId = NodeId.make("node-attempt-1-root");
843 const supersededChildNodeId = NodeId.make("node-attempt-1-child");
844 const activeRootNodeId = NodeId.make("node-attempt-2-root");
845 const supersededAttemptId = RunAttemptId.make("attempt-1");
846 const activeAttemptId = RunAttemptId.make("attempt-2");
847 const providerInstanceId = ProviderInstanceId.make("codex-default");
848 const providerThreadId = ProviderThreadId.make("provider-thread-attempts");
849 const attempts: ReadonlyArray<OrchestrationV2RunAttempt> = [
850 {
851 id: supersededAttemptId,
852 runId,
853 attemptOrdinal: 1,
854 rootNodeId: supersededRootNodeId,
855 providerInstanceId,
856 providerThreadId,
857 providerTurnId: null,
858 reason: "initial",
859 status: "superseded",
860 startedAt: now,
861 completedAt: now,
862 },
863 {
864 id: activeAttemptId,
865 runId,
866 attemptOrdinal: 2,
867 rootNodeId: activeRootNodeId,
868 providerInstanceId,
869 providerThreadId,
870 providerTurnId: null,
871 reason: "steering_restart",
872 status: "running",
873 startedAt: now,
874 completedAt: null,
875 },
876 ];
877 const node = (
878 id: OrchestrationV2ExecutionNode["id"],
879 rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"],
880 parentNodeId: OrchestrationV2ExecutionNode["parentNodeId"],
881 ): OrchestrationV2ExecutionNode => ({
882 id,
883 threadId,
884 runId,
885 parentNodeId,
886 rootNodeId,
887 kind: id === rootNodeId ? "root_turn" : "assistant_message",
888 status: "running",
889 countsForRun: true,
890 providerThreadId,
891 providerTurnId: null,
892 nativeItemRef: null,
893 runtimeRequestId: null,
894 checkpointScopeId: null,
895 startedAt: now,
896 completedAt: null,
897 });
898 const nodes = [
899 node(supersededRootNodeId, supersededRootNodeId, null),
900 node(supersededChildNodeId, supersededRootNodeId, supersededRootNodeId),
901 node(activeRootNodeId, activeRootNodeId, null),
902 ];
903 const assistantItem = (
904 id: string,
905 messageId: string,
906 nodeId: NodeId,
907 text: string,
908 ordinal: number,
909 ): OrchestrationV2TurnItem => ({
910 id: TurnItemId.make(id),
911 threadId,
912 runId,
913 nodeId,
914 providerThreadId,
915 providerTurnId: null,
916 nativeItemRef: null,
917 parentItemId: null,
918 ordinal,
919 status: "running",
920 title: null,
921 startedAt: now,
922 completedAt: null,
923 updatedAt: now,
924 type: "assistant_message",
925 messageId: MessageId.make(messageId),
926 text,
927 streaming: true,
928 });
929 const items = [
930 assistantItem(
931 "item-superseded",
932 "message-superseded",
933 supersededChildNodeId,
934 "Partial old response",
935 0,
936 ),
937 assistantItem("item-active", "message-active", activeRootNodeId, "Current response", 1),
938 ];
939 const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = items.map(
940 (item, position) => ({
941 position,
942 visibility: "local",
943 sourceThreadId: threadId,
944 sourceItemId: item.id,
945 item,
946 }),
947 );
948
949 const entries = deriveTimelineEntriesFromVisibleTurnItems({
950 visibleTurnItems,
951 optimisticMessages: [],
952 attempts,
953 nodes,
954 });
955
956 expect(entries.map((entry) => [entry.attempt?.id, entry.attempt?.status])).toEqual([
957 [supersededAttemptId, "superseded"],
958 [activeAttemptId, "running"],
959 ]);
960 const input = { visibleTurnItems, optimisticMessages: [], attempts, nodes };
961 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
962 const nextInput = {
963 ...input,
964 attempts: attempts.map((attempt) =>
965 attempt.id === activeAttemptId ? { ...attempt, status: "superseded" as const } : attempt,
966 ),
967 };
968 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
969 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
970 expect(next.entries.at(-1)?.attempt?.status).toBe("superseded");
971 expect(previous.entries.at(-1)?.attempt?.status).toBe("running");
972 });
973});
974
975describe("native provider presentation in the v2 timeline", () => {
976 const timestamp = DateTime.makeUnsafe("2026-09-04T12:00:00.000Z");
977 const base = {
978 id: TurnItemId.make("native-item"),
979 threadId: ThreadId.make("native-thread"),
980 runId: RunId.make("native-run"),
981 nodeId: null,
982 providerThreadId: null,
983 providerTurnId: null,
984 nativeItemRef: null,
985 parentItemId: null,
986 ordinal: 0,
987 status: "completed" as const,
988 title: null,
989 startedAt: timestamp,
990 completedAt: timestamp,
991 updatedAt: timestamp,
992 };
993 const visible = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
994 position: 0,
995 visibility: "local",
996 sourceThreadId: item.threadId,
997 sourceItemId: item.id,
998 item,
999 });
1000
1001 it("keeps async answers in the question row, including incrementally appended replies", () => {
1002 const requestId = RuntimeRequestId.make("question");
1003 const question: OrchestrationV2TurnItem = {
1004 ...base,
1005 type: "user_input_request",
1006 requestId,
1007 questions: [],
1008 questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} },
1009 };
1010 const reply: OrchestrationV2TurnItem = {
1011 ...base,
1012 id: TurnItemId.make("answer"),
1013 type: "user_message",
1014 messageId: MessageId.make(`async-answer:${requestId}`),
1015 inputIntent: "steer",
1016 text: "Which color?\nBlue",
1017 createdBy: "user",
1018 creationSource: "server",
1019 attachments: [],
1020 };
1021 const input = { visibleTurnItems: [visible(question)], optimisticMessages: [] };
1022 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1023 const nextInput = { ...input, visibleTurnItems: [...input.visibleTurnItems, visible(reply)] };
1024 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1025 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1026 expect(next.entries).toHaveLength(1);
1027 const replyFirst = { ...input, visibleTurnItems: [visible(reply)] };
1028 const replyProjection = deriveTimelineEntriesFromVisibleTurnItemsWithState(replyFirst);
1029 const questionAfterReply = {
1030 ...input,
1031 visibleTurnItems: [...replyFirst.visibleTurnItems, visible(question)],
1032 };
1033 expect(
1034 deriveTimelineEntriesFromVisibleTurnItemsWithState(questionAfterReply, replyProjection)
1035 .entries,
1036 ).toEqual(deriveTimelineEntriesFromVisibleTurnItems(questionAfterReply));
1037
1038 expect(next.entries[0]).toMatchObject({
1039 kind: "work",
1040 entry: { questionAnswer: question.questionAnswer },
1041 });
1042 // A separately paged reply stays visible until its question history is available.
1043 expect(
1044 deriveTimelineEntriesFromVisibleTurnItems({ ...input, visibleTurnItems: [visible(reply)] })[0]
1045 ?.kind,
1046 ).toBe("message");
1047 });
1048
1049 it("excludes checkpoint-only work from the timeline", () => {
1050 const message: OrchestrationV2TurnItem = {
1051 ...base,
1052 type: "assistant_message",
1053 messageId: MessageId.make("done"),
1054 text: "Done",
1055 streaming: false,
1056 };
1057 const checkpoint: OrchestrationV2TurnItem = {
1058 ...base,
1059 id: TurnItemId.make("checkpoint"),
1060 type: "checkpoint",
1061 checkpointId: CheckpointId.make("checkpoint"),
1062 scopeId: CheckpointScopeId.make("scope"),
1063 files: [],
1064 };
1065 const entries = deriveTimelineEntriesFromVisibleTurnItems({
1066 visibleTurnItems: [visible(message), visible(checkpoint)],
1067 optimisticMessages: [],
1068 });
1069 expect(entries.map((entry) => entry.kind)).toEqual(["message"]);
1070 const rows = deriveMessagesTimelineRows({
1071 timelineEntries: entries,
1072 latestRun: {
1073 runId: base.runId,
1074 status: "completed",
1075 startedAt: DateTime.formatIso(timestamp),
1076 completedAt: DateTime.formatIso(timestamp),
1077 },
1078 isWorking: false,
1079 activeTurnStartedAt: null,
1080 turnDiffSummaries: [],
1081 supportsConversationRollback: false,
1082 });
1083 expect(rows.map((row) => row.kind)).toEqual(["message"]);
1084 });
1085
1086 it.each([
1087 { outputIndicatesFailure: true },
1088 { exitCode: 2 },
1089 { output: "bash: foo: command not found" },
1090 ])("keeps completed command failures visible without exposing output: %j", (result) => {
1091 const item = {
1092 ...base,
1093 type: "command_execution" as const,
1094 input: "foo",
1095 ...result,
1096 } satisfies OrchestrationV2TurnItem;
1097 const [entry] = deriveTimelineEntriesFromVisibleTurnItems({
1098 visibleTurnItems: [visible(item)],
1099 optimisticMessages: [],
1100 });
1101 if (entry?.kind !== "work") throw new Error("Expected a command work entry");
1102
1103 expect(entry.entry.detail).toBeUndefined();
1104 expect(entry.entry.command).toBe("foo");
1105 expect(entry.entry.toolLifecycleStatus).toBe("completed");
1106 expect(workEntryDisplayIndicatesToolFailure(entry.entry)).toBe(true);
1107 expect(workEntryIndicatesToolSuccess(entry.entry)).toBe(false);
1108 });
1109
1110 it("retains Claude Read image previews without tool output", () => {
1111 const item = {
1112 ...base,
1113 type: "dynamic_tool" as const,
1114 toolName: "Read",
1115 input: { file_path: "/workspace/reference.png" },
1116 viewedImagePath: "/workspace/reference.png",
1117 } satisfies OrchestrationV2TurnItem;
1118 const [entry] = deriveTimelineEntriesFromVisibleTurnItems({
1119 visibleTurnItems: [visible(item)],
1120 optimisticMessages: [],
1121 });
1122 expect(entry).toMatchObject({
1123 kind: "work",
1124 entry: { viewedImagePath: "/workspace/reference.png" },
1125 });
1126 });
1127
1128 it("labels a read of a bare filename from its structured input", () => {
1129 const item = {
1130 ...base,
1131 type: "dynamic_tool" as const,
1132 toolName: "Read",
1133 input: { file_path: "README" },
1134 output: "project notes",
1135 } satisfies OrchestrationV2TurnItem;
1136 const [entry] = deriveTimelineEntriesFromVisibleTurnItems({
1137 visibleTurnItems: [visible(item)],
1138 optimisticMessages: [],
1139 });
1140 expect(entry).toMatchObject({ kind: "work", entry: { label: "Read README" } });
1141 });
1142
1143 it("keeps browser identity and its source on a completed tool row", () => {
1144 const item = {
1145 ...base,
1146 type: "dynamic_tool" as const,
1147 toolName: "browser_snapshot",
1148 input: {},
1149 output: {},
1150 toolSurface: "browser" as const,
1151 toolIcon: { _tag: "website" as const, pageUrl: "https://example.com/checkout" },
1152 toolSource: { key: "browser-use:browser", name: "Chrome", kind: "browser" as const },
1153 } satisfies OrchestrationV2TurnItem;
1154 const [entry] = deriveTimelineEntriesFromVisibleTurnItems({
1155 visibleTurnItems: [visible(item)],
1156 optimisticMessages: [],
1157 });
1158 expect(entry).toMatchObject({
1159 kind: "work",
1160 entry: {
1161 toolSurface: "browser",
1162 toolIcon: item.toolIcon,
1163 toolSource: item.toolSource,
1164 toolLifecycleStatus: "completed",
1165 },
1166 });
1167 });
1168
1169 it("shows provider-returned images on an assistant message using the environment asset URL", () => {
1170 const attachment = {
1171 type: "image" as const,
1172 id: "assistant-image",
1173 name: "screenshot.png",
1174 mimeType: "image/png",
1175 sizeBytes: 512,
1176 };
1177 const item = {
1178 ...base,
1179 type: "assistant_message" as const,
1180 messageId: MessageId.make("assistant-native-image"),
1181 text: "Here is the screenshot.",
1182 streaming: false,
1183 attachments: [attachment],
1184 } satisfies OrchestrationV2TurnItem;
1185 const [entry] = deriveTimelineEntriesFromVisibleTurnItems({
1186 visibleTurnItems: [visible(item)],
1187 optimisticMessages: [],
1188 attachmentUrlById: new Map([[attachment.id, "https://remote.example/api/assets/screenshot"]]),
1189 });
1190 expect(entry).toMatchObject({
1191 kind: "message",
1192 message: {
1193 role: "assistant",
1194 attachments: [
1195 { ...attachment, previewUrl: "https://remote.example/api/assets/screenshot" },
1196 ],
1197 },
1198 });
1199 });
1200
1201 it("keeps an idle provider task neutral without a completion mark", () => {
1202 const entry = {
1203 id: "idle-tool",
1204 createdAt: DateTime.formatIso(timestamp),
1205 label: "Waiting for the next task",
1206 tone: "tool" as const,
1207 toolLifecycleStatus: "idle" as const,
1208 };
1209 expect(workEntryIndicatesToolSuccess(entry)).toBe(false);
1210 expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false);
1211 });
1212});
1213
1214describe("HTML renders in the timeline", () => {
1215 const runId = RunId.make("render-run");
1216 const at = (second: number) =>
1217 DateTime.makeUnsafe(`2026-09-04T12:00:${String(second).padStart(2, "0")}.000Z`);
1218 const base = (id: string, second: number) => ({
1219 id: TurnItemId.make(id),
1220 threadId: ThreadId.make("render-thread"),
1221 runId,
1222 nodeId: null,
1223 providerThreadId: null,
1224 providerTurnId: null,
1225 nativeItemRef: null,
1226 parentItemId: null,
1227 ordinal: second,
1228 status: "completed" as const,
1229 title: null,
1230 startedAt: at(second),
1231 completedAt: at(second),
1232 updatedAt: at(second),
1233 });
1234 const visible = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
1235 position: item.ordinal,
1236 visibility: "local",
1237 sourceThreadId: item.threadId,
1238 sourceItemId: item.id,
1239 item,
1240 });
1241 const htmlRender = { attachmentId: "render-thread-chart.html", title: "Chart", height: 420 };
1242 const renderCall = (
1243 status: OrchestrationV2TurnItem["status"],
1244 output?: unknown,
1245 ): OrchestrationV2TurnItem => ({
1246 ...base("render", 2),
1247 status,
1248 type: "dynamic_tool",
1249 toolName: "mcp__t3-code__html_render",
1250 input: { title: "Chart", height: 420 },
1251 ...(output === undefined ? {} : { output }),
1252 });
1253 const command = (id: string, second: number): OrchestrationV2TurnItem => ({
1254 ...base(id, second),
1255 type: "command_execution",
1256 input: "vp test run",
1257 });
1258 const turn = (render: OrchestrationV2TurnItem) => {
1259 const items: OrchestrationV2TurnItem[] = [
1260 {
1261 ...base("prompt", 0),
1262 type: "user_message",
1263 messageId: MessageId.make("prompt"),
1264 inputIntent: "turn_start",
1265 text: "Chart it",
1266 createdBy: "user",
1267 creationSource: "server",
1268 attachments: [],
1269 },
1270 command("before", 1),
1271 render,
1272 command("after", 3),
1273 {
1274 ...base("reply", 4),
1275 type: "assistant_message",
1276 messageId: MessageId.make("reply"),
1277 text: "Here it is.",
1278 streaming: false,
1279 },
1280 ];
1281 return deriveTimelineEntriesFromVisibleTurnItems({
1282 visibleTurnItems: items.map(visible),
1283 optimisticMessages: [],
1284 });
1285 };
1286 const rowsFor = (entries: TimelineEntry[], expanded: boolean) =>
1287 deriveMessagesTimelineRows({
1288 timelineEntries: entries,
1289 latestRun: {
1290 runId,
1291 status: "completed",
1292 startedAt: DateTime.formatIso(at(0)),
1293 completedAt: DateTime.formatIso(at(5)),
1294 },
1295 ...(expanded ? { expandedRunIds: new Set([runId]) } : {}),
1296 isWorking: false,
1297 activeTurnStartedAt: null,
1298 turnDiffSummaries: [],
1299 supportsConversationRollback: false,
1300 }).map((row) => row.kind);
1301
1302 it("shows a completed render in place, above the reply, through the turn fold", () => {
1303 const entries = turn(
1304 renderCall("completed", { structuredContent: { htmlRender }, content: [] }),
1305 );
1306 expect(entries.find((entry) => entry.kind === "html-render")).toMatchObject({
1307 id: "render",
1308 runId,
1309 htmlRender,
1310 });
1311 expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "html-render", "message"]);
1312 expect(rowsFor(entries, true)).toEqual([
1313 "message",
1314 "turn-fold",
1315 "work",
1316 "html-render",
1317 "work",
1318 "message",
1319 ]);
1320 });
1321
1322 it("hosts a captured MCP app in place, from any provider's tool", () => {
1323 const mcpApp = {
1324 attachmentId: "render-thread-app-html",
1325 server: "weather",
1326 tool: "get_weather",
1327 resourceUri: "ui://weather/dashboard",
1328 };
1329 const call = renderCall("completed", { t3McpApp: mcpApp, result: { content: [] } });
1330 const entries = turn(
1331 call.type === "dynamic_tool" ? { ...call, toolName: "weather.get_weather" } : call,
1332 );
1333 expect(entries.find((entry) => entry.kind === "mcp-app")).toMatchObject({
1334 id: "render",
1335 itemId: "render",
1336 mcpApp,
1337 });
1338 expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "mcp-app", "message"]);
1339 });
1340
1341 it("keeps a render visible when its superseded attempt folds", () => {
1342 const attempt: OrchestrationV2RunAttempt = {
1343 id: RunAttemptId.make("attempt-superseded"),
1344 runId,
1345 attemptOrdinal: 1,
1346 rootNodeId: NodeId.make("node-superseded"),
1347 providerInstanceId: ProviderInstanceId.make("codex-default"),
1348 providerThreadId: ProviderThreadId.make("provider-thread"),
1349 providerTurnId: null,
1350 reason: "initial",
1351 status: "superseded",
1352 startedAt: at(0),
1353 completedAt: at(4),
1354 };
1355 const entries = turn(
1356 renderCall("completed", { structuredContent: { htmlRender }, content: [] }),
1357 ).map((entry) =>
1358 entry.kind === "message" && entry.message.role === "user" ? entry : { ...entry, attempt },
1359 );
1360 expect(rowsFor(entries, true)).toEqual(["message", "turn-fold", "attempt-fold", "html-render"]);
1361 });
1362
1363 it.each([
1364 ["running", "running", undefined],
1365 ["failed", "failed", { content: [{ type: "text", text: "Invalid HTML" }], isError: true }],
1366 ["errored", "completed", { structuredContent: { htmlRender }, isError: true }],
1367 ] as const)("keeps a %s call in the work log", (_label, status, output) => {
1368 const entries = turn(renderCall(status, output));
1369 expect(entries.some((entry) => entry.kind === "html-render")).toBe(false);
1370 expect(entries.find((entry) => entry.id === "render")?.kind).toBe("work");
1371 });
1372});
1373
1374describe("work-log failure policy (#7999/#7893)", () => {
1375 const toolEntry = (overrides: Record<string, unknown>) =>
1376 ({
1377 id: "entry-1",
1378 createdAt: "2026-08-27T00:00:00.000Z",
1379 label: "Ran command",
1380 tone: "tool",
1381 ...overrides,
1382 }) as never;
1383
1384 it("flags success-status rows whose output text reports a failure", () => {
1385 expect(
1386 workEntryIndicatesToolFailure(
1387 toolEntry({ toolLifecycleStatus: "completed", detail: "bash: foo: command not found" }),
1388 ),
1389 ).toBe(true);
1390 expect(
1391 workEntryIndicatesToolFailure(
1392 toolEntry({ toolLifecycleStatus: "completed", detail: "<exited with exit code 2>" }),
1393 ),
1394 ).toBe(true);
1395 });
1396
1397 it("keeps the rendered row calm when only the command mentions failure text", () => {
1398 const entry = toolEntry({
1399 toolLifecycleStatus: "completed",
1400 command: "rg 'command not found' src/",
1401 });
1402 expect(workEntryIndicatesToolFailure(entry)).toBe(true);
1403 expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false);
1404 });
1405
1406 it("does not call a clean completed row failed", () => {
1407 const entry = toolEntry({ toolLifecycleStatus: "completed", detail: "3 files changed" });
1408 expect(workEntryIndicatesToolFailure(entry)).toBe(false);
1409 expect(workEntryIndicatesToolSuccess(entry)).toBe(true);
1410 });
1411
1412 it("recovered failure text no longer counts as success", () => {
1413 const entry = toolEntry({ toolLifecycleStatus: "completed", detail: "ENOENT: no such file" });
1414 expect(workEntryIndicatesToolSuccess(entry)).toBe(false);
1415 });
1416});
1417
1418describe("incremental v2 timeline entries", () => {
1419 it("retains history and attachment previews through decoded streaming updates", () => {
1420 const fixture = makeStreamingTimelineFixture("Partial");
1421 const attachment = {
1422 type: "image" as const,
1423 id: "stream-image",
1424 name: "image.png",
1425 mimeType: "image/png",
1426 sizeBytes: 42,
1427 };
1428 const visibleTurnItems = fixture.visibleTurnItems.map((row) =>
1429 row.item.type === "assistant_message"
1430 ? { ...row, item: { ...row.item, attachments: [attachment] } }
1431 : row,
1432 );
1433 const input = {
1434 visibleTurnItems,
1435 optimisticMessages: [],
1436 attachmentUrlById: new Map([[attachment.id, "https://server.test/image"]]),
1437 };
1438 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1439 Object.freeze(previous.entries);
1440 for (const entry of previous.entries) Object.freeze(entry);
1441 const last = visibleTurnItems.at(-1)!;
1442 if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1443 const nextItem = {
1444 ...last,
1445 item: {
1446 ...last.item,
1447 text: "Next token",
1448 attachments: [{ ...attachment }],
1449 startedAt: DateTime.makeUnsafe(fixture.time(7)),
1450 updatedAt: DateTime.makeUnsafe(fixture.time(8)),
1451 },
1452 };
1453 const nextInput = {
1454 ...input,
1455 visibleTurnItems: [...visibleTurnItems.slice(0, -1), nextItem],
1456 attachmentUrlById: new Map(input.attachmentUrlById),
1457 };
1458 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1459 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1460 for (const [index, entry] of previous.entries.slice(0, -1).entries()) {
1461 expect(next.entries[index]).toBe(entry);
1462 }
1463 const beforeMessage = previous.entries.at(-1)!;
1464 const nextMessage = next.entries.at(-1)!;
1465 if (beforeMessage.kind !== "message" || nextMessage.kind !== "message") {
1466 throw new Error("Expected assistant entries");
1467 }
1468 expect(nextMessage.message.attachments).toBe(beforeMessage.message.attachments);
1469 expect(nextMessage.projectedItem).toBe(nextItem);
1470 expect(nextMessage.message.text).toBe("Next token");
1471 expect(beforeMessage.message.text).toBe("Partial");
1472
1473 const renewedInput = {
1474 ...nextInput,
1475 attachmentUrlById: new Map([[attachment.id, "https://renewed.test/image"]]),
1476 };
1477 const renewed = deriveTimelineEntriesFromVisibleTurnItemsWithState(renewedInput, next);
1478 expect(renewed.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(renewedInput));
1479 expect(renewed.entries.at(-1)).toMatchObject({
1480 message: { attachments: [{ previewUrl: "https://renewed.test/image" }] },
1481 });
1482 expect(nextMessage.message.attachments?.[0]).toMatchObject({
1483 previewUrl: "https://server.test/image",
1484 });
1485 const restoredInput = { ...renewedInput, attachmentUrlById: new Map<string, string>() };
1486 const restored = deriveTimelineEntriesFromVisibleTurnItemsWithState(restoredInput, renewed);
1487 expect(restored.entries.at(-1)).toMatchObject({ message: { attachments: [attachment] } });
1488 const restoredMessage = restored.entries.at(-1)!;
1489 if (restoredMessage.kind !== "message") throw new Error("Expected assistant entry");
1490 expect(restoredMessage.message.attachments?.[0]).not.toHaveProperty("previewUrl");
1491 });
1492
1493 it("appends committed items in canonical order even when their timestamps go backwards", () => {
1494 const fixture = makeStreamingTimelineFixture("Partial");
1495 const input = { visibleTurnItems: fixture.visibleTurnItems, optimisticMessages: [] };
1496 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1497 const last = fixture.visibleTurnItems.at(-1)!;
1498 const appended = {
1499 ...last,
1500 position: last.position + 1,
1501 sourceItemId: TurnItemId.make("late-item"),
1502 item: {
1503 ...last.item,
1504 id: TurnItemId.make("late-item"),
1505 messageId: MessageId.make("late-message"),
1506 startedAt: DateTime.makeUnsafe(fixture.time(0)),
1507 },
1508 };
1509 const nextInput = { ...input, visibleTurnItems: [...input.visibleTurnItems, appended] };
1510 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1511 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1512 expect(next.entries.at(-1)).toMatchObject({ id: "late-message" });
1513 for (const [index, entry] of previous.entries.entries())
1514 expect(next.entries[index]).toBe(entry);
1515 });
1516
1517 it.each(["completion", "provenance", "ordering", "attachment", "run"] as const)(
1518 "rebuilds entries for a %s change instead of retaining stale v2 metadata",
1519 (change) => {
1520 const fixture = makeStreamingTimelineFixture("Partial");
1521 const input = { visibleTurnItems: fixture.visibleTurnItems, optimisticMessages: [] };
1522 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1523 const last = fixture.visibleTurnItems.at(-1)!;
1524 if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1525 const changed =
1526 change === "provenance"
1527 ? { ...last, sourceThreadId: ThreadId.make("inherited-thread") }
1528 : {
1529 ...last,
1530 item:
1531 change === "completion"
1532 ? { ...last.item, streaming: false, status: "completed" as const }
1533 : change === "ordering"
1534 ? { ...last.item, startedAt: DateTime.makeUnsafe(fixture.time(0)) }
1535 : change === "run"
1536 ? { ...last.item, runId: fixture.historyRunId }
1537 : {
1538 ...last.item,
1539 attachments: [
1540 {
1541 type: "image",
1542 id: "new-image",
1543 name: "new.png",
1544 mimeType: "image/png",
1545 sizeBytes: 42,
1546 },
1547 ],
1548 },
1549 };
1550 const nextInput = {
1551 ...input,
1552 visibleTurnItems: [...input.visibleTurnItems.slice(0, -1), changed],
1553 };
1554 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1555 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1556 expect(next.entries.at(-1)).not.toBe(previous.entries.at(-1));
1557 },
1558 );
1559
1560 it("updates fallback timestamps when the provider has not supplied a message start time", () => {
1561 const fixture = makeStreamingTimelineFixture("Partial");
1562 const visibleTurnItems = fixture.visibleTurnItems.map((row) =>
1563 row.item.type === "assistant_message" && row.item.streaming
1564 ? { ...row, item: { ...row.item, startedAt: null } }
1565 : row,
1566 );
1567 const input = { visibleTurnItems, optimisticMessages: [] };
1568 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1569 const nextInput = {
1570 ...input,
1571 visibleTurnItems: visibleTurnItems.map((row) =>
1572 row.item.type === "assistant_message" && row.item.streaming
1573 ? {
1574 ...row,
1575 item: {
1576 ...row.item,
1577 text: "Next token",
1578 updatedAt: DateTime.makeUnsafe(fixture.time(8)),
1579 },
1580 }
1581 : row,
1582 ),
1583 };
1584 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1585 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1586 expect(next.entries.at(-1)?.createdAt).toBe(fixture.time(8));
1587 });
1588
1589 it("keeps unchanged message previews while tool output rebuilds the work log", () => {
1590 const fixture = makeStreamingTimelineFixture("Partial");
1591 const input = { visibleTurnItems: fixture.visibleTurnItems, optimisticMessages: [] };
1592 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1593 const nextInput = {
1594 ...input,
1595 visibleTurnItems: input.visibleTurnItems.map((row) =>
1596 row.item.type === "command_execution"
1597 ? { ...row, item: { ...row.item, output: "Additional output" } }
1598 : row,
1599 ),
1600 };
1601 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1602 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1603 for (const [index, entry] of previous.entries.entries()) {
1604 if (entry.kind === "message") expect(next.entries[index]).toBe(entry);
1605 }
1606 });
1607
1608 it("deduplicates optimistic sends when committed items arrive and retains anchored ordering", () => {
1609 const fixture = makeStreamingTimelineFixture();
1610 const input = {
1611 visibleTurnItems: fixture.visibleTurnItems.slice(0, 3),
1612 optimisticMessages: [
1613 {
1614 id: MessageId.make("live-user"),
1615 runId: null,
1616 role: "user" as const,
1617 text: "Continue",
1618 streaming: false,
1619 createdAt: fixture.time(5),
1620 updatedAt: fixture.time(5),
1621 },
1622 ],
1623 anchoredMessages: [
1624 {
1625 id: MessageId.make("feedback"),
1626 runId: null,
1627 role: "assistant" as const,
1628 text: "Feedback received",
1629 streaming: false,
1630 createdAt: fixture.time(4),
1631 updatedAt: fixture.time(4),
1632 },
1633 ],
1634 };
1635 const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState(input);
1636 const nextInput = { ...input, visibleTurnItems: fixture.visibleTurnItems };
1637 const next = deriveTimelineEntriesFromVisibleTurnItemsWithState(nextInput, previous);
1638 expect(next.entries).toEqual(deriveTimelineEntriesFromVisibleTurnItems(nextInput));
1639 expect(next.entries.filter((entry) => entry.id === "live-user")).toHaveLength(1);
1640 expect(next.entries.map((entry) => entry.id)).toEqual([
1641 "history-user",
1642 "history-work",
1643 "history-assistant",
1644 "feedback",
1645 "live-user",
1646 "live-work",
1647 "live-assistant",
1648 ]);
1649 });
1650});
1651
1652describe("image asset requests", () => {
1653 const image = {
1654 type: "image" as const,
1655 id: "image",
1656 name: "image.png",
1657 mimeType: "image/png",
1658 sizeBytes: 42,
1659 };
1660 const message = {
1661 id: MessageId.make("image-message"),
1662 role: "user" as const,
1663 text: "Inspect these images",
1664 runId: null,
1665 createdAt: "2026-09-04T00:00:00.000Z",
1666 updatedAt: "2026-09-04T00:00:00.000Z",
1667 streaming: false,
1668 attachments: [image],
1669 };
1670
1671 it("requests the whole row's gallery and crops without signing local preview IDs", () => {
1672 const attachments = Object.freeze([
1673 image,
1674 { ...image, id: "second" },
1675 { ...image, id: "crop", name: "preview-annotation-1.png" },
1676 { ...image, id: "local", previewUrl: "blob:local" },
1677 { ...image, id: "inline", previewUrl: "data:image/png;base64,AA==" },
1678 { ...image, id: "provided", previewUrl: "https://preview.test/image" },
1679 { ...image, type: "file" as const, id: "file", mimeType: "application/pdf" },
1680 { ...image, type: "future", id: "unknown" },
1681 image,
1682 ]);
1683
1684 expect(selectMessageImageResources(attachments)).toEqual([
1685 { _tag: "attachment", attachmentId: "image" },
1686 { _tag: "attachment", attachmentId: "second" },
1687 { _tag: "attachment", attachmentId: "crop" },
1688 { _tag: "attachment", attachmentId: "provided" },
1689 ]);
1690 });
1691
1692 it("requests offscreen handoffs without signing the rest of the loaded history", () => {
1693 const history = {
1694 ...message,
1695 id: MessageId.make("history"),
1696 attachments: [{ ...image, id: "history-image" }],
1697 };
1698 const offscreen = {
1699 ...message,
1700 id: MessageId.make("offscreen"),
1701 attachments: [image, { ...image, id: "crop", name: "preview-annotation-1.png" }],
1702 };
1703 const empty = {
1704 ...message,
1705 id: MessageId.make("empty"),
1706 attachments: [{ ...image, id: "empty" }],
1707 };
1708 const assistant = {
1709 ...message,
1710 id: MessageId.make("assistant"),
1711 role: "assistant" as const,
1712 attachments: [{ ...image, id: "assistant-image" }],
1713 };
1714 expect(
1715 selectHandoffImageResources([history, message, offscreen, empty, assistant], {
1716 [message.id]: ["blob:message"],
1717 [offscreen.id]: ["blob:offscreen", "blob:crop"],
1718 [empty.id]: [],
1719 [assistant.id]: ["blob:unused"],
1720 }),
1721 ).toEqual([
1722 { _tag: "attachment", attachmentId: "image" },
1723 { _tag: "attachment", attachmentId: "crop" },
1724 ]);
1725 });
1726
1727 it("does not scan history when no handoff is pending", () => {
1728 let reads = 0;
1729 const messages = new Proxy([message], {
1730 get(target, property, receiver) {
1731 if (property === "0") reads += 1;
1732 return Reflect.get(target, property, receiver);
1733 },
1734 });
1735 const empty = selectHandoffImageResources(messages, {});
1736 expect(reads).toBe(0);
1737 expect(empty).toHaveLength(0);
1738 expect(selectHandoffImageResources(undefined, { missing: ["blob:missing"] })).toBe(empty);
1739 expect(selectMessageImageResources(undefined)).toBe(empty);
1740 });
1741
1742 it("hands signed URLs to a mounted row only after the local preview is released", () => {
1743 const server = createMessageAttachmentPreviewProjector();
1744 const handoff = createMessageAttachmentPreviewProjector();
1745 const row = createMessageAttachmentPreviewProjector();
1746 const pending = handoff(
1747 server(message, () => undefined),
1748 () => "blob:pending",
1749 );
1750 expect(selectMessageImageResources(pending.attachments)).toEqual([]);
1751 expect(selectHandoffImageResources([message], { [message.id]: ["blob:pending"] })).toEqual([
1752 { _tag: "attachment", attachmentId: image.id },
1753 ]);
1754
1755 const ready = server(message, () => "https://server.test/image");
1756 expect(selectMessageImageResources(handoff(ready, () => "blob:pending").attachments)).toEqual(
1757 [],
1758 );
1759 const released = server(message, () => undefined);
1760 expect(selectMessageImageResources(released.attachments)).toEqual([
1761 { _tag: "attachment", attachmentId: image.id },
1762 ]);
1763 const displayed = row(released, () => "https://server.test/image");
1764 expect(displayed).toEqual(ready);
1765 expect(pending.attachments?.[0]).toMatchObject({ previewUrl: "blob:pending" });
1766 expect(row(released, () => "https://server.test/renewed").attachments?.[0]).toMatchObject({
1767 previewUrl: "https://server.test/renewed",
1768 });
1769 expect(displayed.attachments?.[0]).toMatchObject({ previewUrl: "https://server.test/image" });
1770 expect(row(released, () => undefined)).toBe(message);
1771 });
1772});
1773
1774it("renders automatic completion as a work entry instead of a user bubble", () => {
1775 const now = DateTime.makeUnsafe("2026-09-09T00:00:00Z");
1776 const item = {
1777 id: TurnItemId.make("wake-item"),
1778 threadId: ThreadId.make("parent"),
1779 runId: RunId.make("wake-run"),
1780 nodeId: null,
1781 providerThreadId: null,
1782 providerTurnId: null,
1783 nativeItemRef: null,
1784 parentItemId: null,
1785 ordinal: 0,
1786 status: "completed" as const,
1787 title: null,
1788 startedAt: now,
1789 completedAt: now,
1790 updatedAt: now,
1791 type: "notification" as const,
1792 source: { kind: "delegated_task" as const, taskIds: [NodeId.make("task-1")] },
1793 outcome: "unknown" as const,
1794 summary: "Delegated task finished",
1795 };
1796 const row = {
1797 item,
1798 position: 0,
1799 visibility: "local" as const,
1800 sourceThreadId: item.threadId,
1801 sourceItemId: item.id,
1802 };
1803 const entries = deriveTimelineEntriesFromVisibleTurnItems({
1804 optimisticMessages: [],
1805 visibleTurnItems: [row],
1806 });
1807 expect(entries).toHaveLength(1);
1808 expect(entries[0]).toMatchObject({
1809 kind: "work",
1810 entry: { label: "Delegated task finished", tone: "info", projectedItem: row },
1811 });
1812 expect(
1813 deriveTimelineEntriesFromVisibleTurnItems({
1814 optimisticMessages: [],
1815 visibleTurnItems: [
1816 {
1817 ...row,
1818 item: {
1819 ...item,
1820 type: "user_message",
1821 messageId: MessageId.make("wake-message"),
1822 createdBy: "agent" as const,
1823 creationSource: "server" as const,
1824 inputIntent: "turn_start" as const,
1825 attachments: [],
1826 text: "Delegated task node:task-1 reached a terminal state. Use task_status with taskId node:task-1 to read the result.",
1827 },
1828 },
1829 ],
1830 })[0]?.kind,
1831 ).toBe("message");
1832});