1import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
2import { ThreadId, type WorktreeSetupSnapshot } from "@t3tools/contracts";
3import {
4 CheckpointRef,
5 NodeId,
6 ProviderDriverKind,
7 ProviderInstanceId,
8 ProviderTurnId,
9 RunAttemptId,
10 TurnItemId,
11 RuntimeRequestId,
12 type OrchestrationV2ProjectedTurnItem,
13 type OrchestrationV2TurnItem,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import {
17 deriveTimelineEntriesFromVisibleTurnItems,
18 deriveTimelineEntriesFromVisibleTurnItemsWithState,
19 workEntryDisplayIndicatesToolFailure,
20 type TimelineEntry,
21} from "../../session-logic";
22import { makeStreamingTimelineFixture } from "../../test-fixtures";
23import type { TurnDiffSummary } from "../../types";
24import { describe, expect, it } from "vite-plus/test";
25import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts";
26import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
27import {
28 computeStableMessagesTimelineRows,
29 computeMessageDurationStart,
30 deriveMessagesTimelineRows,
31 timelineEntryTurnFoldRunId,
32 deriveMessagesTimelineRowsWithState,
33 shouldCollapseUserMessage,
34 liveWorkEntryLabel,
35 normalizeCompactToolLabel,
36 resolveAssistantMessageCopyState,
37 resolveWorkGroupScrollIndex,
38 shouldFollowWorkGroupAppend,
39 threadReadLabelPrefix,
40 threadReadTargetId,
41 threadReadTargetTitle,
42 type MessagesTimelineRow,
43 resolveTimelineToolPresentation,
44 workEntryDisplayLabel,
45 workEntryReadOutput,
46 workEntryIsVisibleInGroup,
47} from "./MessagesTimeline.logic";
48import type { WorkLogEntry } from "../../session-logic";
49
50describe("expanded tool group scrolling", () => {
51 const entries = [{ id: "first" }, { id: "second" }];
52
53 it("follows appended calls only at the hard end", () => {
54 const appended = [...entries, { id: "third" }];
55 expect(shouldFollowWorkGroupAppend(entries, appended, 0)).toBe(true);
56 expect(shouldFollowWorkGroupAppend(entries, appended, 0.5)).toBe(true);
57 expect(shouldFollowWorkGroupAppend(entries, appended, 1)).toBe(true);
58 expect(shouldFollowWorkGroupAppend(entries, appended, 1.01)).toBe(false);
59 expect(shouldFollowWorkGroupAppend(entries, appended, 10)).toBe(false);
60 expect(shouldFollowWorkGroupAppend(entries, appended, Infinity)).toBe(false);
61 });
62
63 it("does not follow output updates, prepends, or replacements", () => {
64 expect(
65 shouldFollowWorkGroupAppend(
66 entries,
67 entries.map((entry) => ({ ...entry })),
68 0,
69 ),
70 ).toBe(false);
71 expect(shouldFollowWorkGroupAppend(entries, [{ id: "older" }, ...entries], 0)).toBe(false);
72 expect(
73 shouldFollowWorkGroupAppend(
74 entries,
75 [{ id: "replacement" }, entries[1]!, { id: "third" }],
76 0,
77 ),
78 ).toBe(false);
79 expect(shouldFollowWorkGroupAppend([], entries, 0)).toBe(false);
80 });
81
82 it("restores the visible tool and its offset inside expanded output", () => {
83 const anchor = { entryId: "second", offset: 120 };
84 expect(resolveWorkGroupScrollIndex(entries, anchor)).toEqual({ index: 1, viewOffset: -120 });
85 expect(resolveWorkGroupScrollIndex([{ id: "older" }, ...entries], anchor)).toEqual({
86 index: 2,
87 viewOffset: -120,
88 });
89 });
90
91 it("starts normally when the saved tool no longer exists", () => {
92 expect(resolveWorkGroupScrollIndex(entries, undefined)).toBeUndefined();
93 expect(
94 resolveWorkGroupScrollIndex(entries, { entryId: "removed", offset: 120 }),
95 ).toBeUndefined();
96 });
97});
98
99describe("work entry labels", () => {
100 it("uses live titles only for active thread shells", () => {
101 const shell = { title: " Review auth flow ", archivedAt: null, deletedAt: null };
102 expect(threadReadTargetTitle(shell)).toBe("Review auth flow");
103 expect(threadReadTargetTitle({ ...shell, title: "Harden session refresh" })).toBe(
104 "Harden session refresh",
105 );
106 expect(threadReadTargetTitle({ ...shell, archivedAt: "2026-10-07T12:00:00Z" })).toBeNull();
107 expect(threadReadTargetTitle({ ...shell, deletedAt: "2026-10-07T12:00:00Z" })).toBeNull();
108 expect(threadReadTargetTitle({ ...shell, title: " " })).toBeNull();
109 expect(threadReadTargetTitle(null)).toBeNull();
110 });
111
112 const entry = {
113 id: "tool-1",
114 createdAt: "2026-09-01T12:00:00Z",
115 label: "Tool call",
116 tone: "tool" as const,
117 };
118
119 it("previews reasoning in the live row and falls back to a short label while empty", () => {
120 const thought = {
121 ...entry,
122 itemType: "reasoning" as const,
123 tone: "thinking" as const,
124 detail: "Check **ordering** first.",
125 toolLifecycleStatus: "inProgress" as const,
126 };
127 expect(liveWorkEntryLabel(thought, undefined, true)).toBe(thought.detail);
128 expect(
129 liveWorkEntryLabel(
130 { ...thought, detail: "First paragraph.\n\nSecond paragraph." },
131 undefined,
132 true,
133 ),
134 ).toBe("First paragraph. Second paragraph.");
135 expect(liveWorkEntryLabel({ ...thought, detail: " " }, undefined, true)).toBe("Thinking");
136 expect(
137 liveWorkEntryLabel(
138 { ...thought, detail: "", toolLifecycleStatus: "completed" },
139 undefined,
140 false,
141 ),
142 ).toBe("Thought");
143 expect(
144 liveWorkEntryLabel({ ...thought, toolLifecycleStatus: "completed" }, undefined, false),
145 ).toBe(thought.detail);
146 expect(workEntryDisplayLabel(thought, undefined)).toBe(thought.detail);
147 expect(workEntryIsVisibleInGroup(thought)).toBe(true);
148 expect(workEntryIsVisibleInGroup({ ...thought, toolLifecycleStatus: "completed" })).toBe(true);
149 expect(workEntryIsVisibleInGroup({ ...thought, detail: " " })).toBe(false);
150 });
151
152 it.each([
153 ["inProgress", "Clicking in the preview browser"],
154 ["completed", "Clicked in the preview browser"],
155 ["failed", "Failed to click in the preview browser"],
156 ["declined", "Declined to click in the preview browser"],
157 ["stopped", "Stopped clicking in the preview browser"],
158 ] as const)("uses the same friendly %s label in both views", (toolLifecycleStatus, label) => {
159 const browserEntry = {
160 ...entry,
161 toolTitle: "T3-code.preview_click",
162 detail: '{"ok":true}',
163 toolLifecycleStatus,
164 };
165 expect(liveWorkEntryLabel(browserEntry, undefined, toolLifecycleStatus === "inProgress")).toBe(
166 label,
167 );
168 expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label);
169 });
170
171 it.each([
172 ["inProgress", true, "Reading thread"],
173 ["completed", false, "Read thread"],
174 ["failed", false, "Failed to read thread"],
175 ["declined", false, "Declined to read thread"],
176 ["stopped", false, "Stopped reading thread"],
177 ] as const)("names the read thread in the %s label", (toolLifecycleStatus, active, prefix) => {
178 const threadRead = {
179 ...entry,
180 itemType: "dynamic_tool" as const,
181 toolLifecycleStatus,
182 structuredPayload: {
183 type: "dynamic_tool",
184 toolName: "t3-code.t3_thread_read",
185 input: { threadId: " thread-child ", view: "activity" },
186 } as never,
187 };
188 expect(threadReadTargetId(threadRead)).toBe("thread-child");
189 expect(threadReadLabelPrefix(liveWorkEntryLabel(threadRead, undefined, active))).toBe(prefix);
190 expect(threadReadLabelPrefix(workEntryDisplayLabel(threadRead, undefined))).toBe(prefix);
191 });
192
193 it("finds no target for other tools or thread reads without one", () => {
194 const payload = (toolName: string, input: unknown) => ({
195 structuredPayload: { type: "dynamic_tool", toolName, input } as never,
196 });
197 expect(threadReadTargetId(payload("t3-code.t3_thread_wait", { threadId: "t" }))).toBeNull();
198 expect(threadReadTargetId(payload("t3-code.t3_thread_read", { threadId: " " }))).toBeNull();
199 expect(threadReadTargetId(payload("t3-code.t3_thread_read", null))).toBeNull();
200 });
201
202 it("uses the active summary state for legacy tools without a lifecycle status", () => {
203 const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" };
204 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
205 "Clicking in the preview browser",
206 );
207 expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe(
208 "Clicked in the preview browser",
209 );
210 });
211
212 it("keeps the latest live activity in the present tense after the call completes", () => {
213 const browserEntry = {
214 ...entry,
215 toolTitle: "T3-code.preview_click",
216 toolLifecycleStatus: "completed" as const,
217 };
218 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
219 "Clicking in the preview browser",
220 );
221 expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe(
222 "Clicked in the preview browser",
223 );
224 });
225
226 it("labels file reads with the path and never the file body", () => {
227 const readEntry = {
228 ...entry,
229 itemType: "dynamic_tool" as const,
230 toolTitle: "Read",
231 label: "Read",
232 detail: "---\nname: env\n---\n cons t x = 1",
233 toolData: { input: { file_path: "src/env.ts" } },
234 structuredPayload: {
235 type: "dynamic_tool",
236 toolName: "Read",
237 input: { file_path: "src/env.ts" },
238 } as NonNullable<WorkLogEntry["structuredPayload"]>,
239 };
240 expect(workEntryDisplayLabel(readEntry, undefined)).toBe("Read src/env.ts");
241 expect(workEntryReadOutput(readEntry, undefined)).toBe("src/env.ts");
242 expect(workEntryReadOutput(readEntry, "/workspace/ohseearr")).toBe(
243 "/workspace/ohseearr/src/env.ts",
244 );
245 expect(
246 workEntryReadOutput(
247 {
248 structuredPayload: {
249 type: "dynamic_tool",
250 toolName: "Read",
251 input: {},
252 } as NonNullable<WorkLogEntry["structuredPayload"]>,
253 toolData: { locations: [{ path: "src/from-location.ts" }] },
254 },
255 "/workspace/ohseearr",
256 ),
257 ).toBe("/workspace/ohseearr/src/from-location.ts");
258 expect(workEntryReadOutput({ detail: "---", toolData: {} }, undefined)).toBeNull();
259 });
260
261 it("labels Claude Grep from structured input instead of a generic tool heading", () => {
262 expect(
263 workEntryDisplayLabel(
264 {
265 ...entry,
266 itemType: "dynamic_tool",
267 toolTitle: "Grep",
268 label: "Grep",
269 toolData: { input: { pattern: "TODO", path: "apps/web" } },
270 structuredPayload: {
271 type: "dynamic_tool",
272 toolName: "Grep",
273 input: { pattern: "TODO", path: "apps/web" },
274 } as NonNullable<WorkLogEntry["structuredPayload"]>,
275 },
276 undefined,
277 ),
278 ).toBe("Searched TODO in web");
279 });
280
281 it("labels file searches with the adapter title and its search target", () => {
282 expect(
283 workEntryDisplayLabel(
284 {
285 ...entry,
286 itemType: "file_search",
287 label: "Searched TODO in web",
288 toolTitle: "Searched TODO in web",
289 detail: "TODO",
290 toolData: { type: "file_search", pattern: "TODO" },
291 },
292 undefined,
293 ),
294 ).toBe("Searched TODO in web");
295 });
296
297 it("keeps a multi-line approval prompt as its label", () => {
298 const prompt = "Allow this command?\nrm -rf dist";
299 expect(
300 workEntryDisplayLabel(
301 { ...entry, itemType: "approval_request", requestKind: "command", detail: prompt },
302 undefined,
303 ),
304 ).toBe(prompt);
305 });
306
307 it("keeps custom titles and output for unrecognized tools", () => {
308 const unknownEntry = { ...entry, toolTitle: "mcp__github__search_issues" };
309 expect(liveWorkEntryLabel(unknownEntry, undefined, true)).toBe("Mcp__github__search_issues");
310 expect(workEntryDisplayLabel({ ...unknownEntry, detail: "Found 3 issues" }, undefined)).toBe(
311 "Found 3 issues",
312 );
313 });
314
315 it("keeps command summaries compact without replacing the full command in expanded rows", () => {
316 const commandEntry = { ...entry, command: "vp test run", detail: "All tests passed" };
317 expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp");
318 expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp");
319 expect(workEntryDisplayLabel(commandEntry, undefined)).toBe("vp test run");
320 });
321
322 it("summarizes the program inside a shell wrapper while preserving the expanded command", () => {
323 const command = "/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'";
324 const commandEntry = { ...entry, command };
325 expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp");
326 expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp");
327 expect(workEntryDisplayLabel(commandEntry, undefined)).toBe(
328 "vp test run apps/web/src/session-logic.test.ts",
329 );
330 expect(commandEntry.command).toBe(command);
331 });
332
333 it.each([
334 ["inProgress", "Running vp", "Running vp"],
335 ["completed", "Running vp", "Ran vp"],
336 ["failed", "Failed vp", "Failed vp"],
337 ["declined", "Declined vp", "Declined vp"],
338 ["stopped", "Stopped vp", "Stopped vp"],
339 ] as const)(
340 "uses present tense for a live %s command and the outcome once it is no longer live",
341 (toolLifecycleStatus, liveLabel, settledLabel) => {
342 const commandEntry = {
343 ...entry,
344 command: "/bin/bash -lc 'vp test run'",
345 toolLifecycleStatus,
346 };
347 expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(liveLabel);
348 expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(settledLabel);
349 },
350 );
351
352 it.each([
353 ["preview_click", "Clicked in the preview browser"],
354 ["task_status", "Got delegated task status"],
355 ] as const)(
356 "renders a settled legacy %s call directly with its completed presentation",
357 (tool, label) => {
358 const rows = deriveMessagesTimelineRows({
359 timelineEntries: [
360 {
361 id: "browser-entry",
362 kind: "work",
363 createdAt: entry.createdAt,
364 entry: {
365 ...entry,
366 itemType: "dynamic_tool",
367 toolData: { server: "t3-code", tool },
368 },
369 },
370 ],
371 isWorking: false,
372 activeTurnStartedAt: null,
373 turnDiffSummaries: [],
374 supportsConversationRollback: false,
375 });
376 const directRow = rows.find((row) => row.kind === "work");
377 expect(directRow).toMatchObject({
378 groupedEntries: [expect.objectContaining({ id: "tool-1" })],
379 isExpandedToolGroup: false,
380 displayLabel: label,
381 });
382 },
383 );
384});
385
386describe("shouldPreserveAssistantLineBreaks", () => {
387 it("preserves Claude insight formatting without changing regular markdown", () => {
388 expect(
389 shouldPreserveAssistantLineBreaks(
390 "★ Insight ─────────────────\\nFirst observation\\nSecond observation\\n─────────────────",
391 ),
392 ).toBe(true);
393 expect(shouldPreserveAssistantLineBreaks("A normal\\nmarkdown paragraph")).toBe(false);
394 });
395});
396
397describe("computeMessageDurationStart", () => {
398 it("returns message createdAt when there is no preceding user message", () => {
399 const result = computeMessageDurationStart([
400 {
401 id: "a1",
402 role: "assistant",
403 createdAt: "2026-01-01T00:00:05Z",
404 updatedAt: "2026-01-01T00:00:10Z",
405 streaming: false,
406 },
407 ]);
408 expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]]));
409 });
410
411 it("uses the user message createdAt for the first assistant response", () => {
412 const result = computeMessageDurationStart([
413 {
414 id: "u1",
415 role: "user",
416 createdAt: "2026-01-01T00:00:00Z",
417 updatedAt: "2026-01-01T00:00:00Z",
418 streaming: false,
419 },
420 {
421 id: "a1",
422 role: "assistant",
423 createdAt: "2026-01-01T00:00:30Z",
424 updatedAt: "2026-01-01T00:00:30Z",
425 streaming: false,
426 },
427 ]);
428
429 expect(result).toEqual(
430 new Map([
431 ["u1", "2026-01-01T00:00:00Z"],
432 ["a1", "2026-01-01T00:00:00Z"],
433 ]),
434 );
435 });
436
437 it("uses the previous completed assistant updatedAt for subsequent assistant responses", () => {
438 const result = computeMessageDurationStart([
439 {
440 id: "u1",
441 role: "user",
442 createdAt: "2026-01-01T00:00:00Z",
443 updatedAt: "2026-01-01T00:00:00Z",
444 streaming: false,
445 },
446 {
447 id: "a1",
448 role: "assistant",
449 createdAt: "2026-01-01T00:00:30Z",
450 updatedAt: "2026-01-01T00:00:30Z",
451 streaming: false,
452 },
453 {
454 id: "a2",
455 role: "assistant",
456 createdAt: "2026-01-01T00:00:55Z",
457 updatedAt: "2026-01-01T00:00:55Z",
458 streaming: false,
459 },
460 ]);
461
462 expect(result).toEqual(
463 new Map([
464 ["u1", "2026-01-01T00:00:00Z"],
465 ["a1", "2026-01-01T00:00:00Z"],
466 ["a2", "2026-01-01T00:00:30Z"],
467 ]),
468 );
469 });
470
471 it("does not advance the boundary for a streaming message", () => {
472 const result = computeMessageDurationStart([
473 {
474 id: "u1",
475 role: "user",
476 createdAt: "2026-01-01T00:00:00Z",
477 updatedAt: "2026-01-01T00:00:00Z",
478 streaming: false,
479 },
480 {
481 id: "a1",
482 role: "assistant",
483 createdAt: "2026-01-01T00:00:30Z",
484 updatedAt: "2026-01-01T00:00:40Z",
485 streaming: true,
486 },
487 {
488 id: "a2",
489 role: "assistant",
490 createdAt: "2026-01-01T00:00:55Z",
491 updatedAt: "2026-01-01T00:00:55Z",
492 streaming: false,
493 },
494 ]);
495
496 expect(result).toEqual(
497 new Map([
498 ["u1", "2026-01-01T00:00:00Z"],
499 ["a1", "2026-01-01T00:00:00Z"],
500 ["a2", "2026-01-01T00:00:00Z"],
501 ]),
502 );
503 });
504
505 it("resets the boundary on a new user message", () => {
506 const result = computeMessageDurationStart([
507 {
508 id: "u1",
509 role: "user",
510 createdAt: "2026-01-01T00:00:00Z",
511 updatedAt: "2026-01-01T00:00:00Z",
512 streaming: false,
513 },
514 {
515 id: "a1",
516 role: "assistant",
517 createdAt: "2026-01-01T00:00:30Z",
518 updatedAt: "2026-01-01T00:00:30Z",
519 streaming: false,
520 },
521 {
522 id: "u2",
523 role: "user",
524 createdAt: "2026-01-01T00:01:00Z",
525 updatedAt: "2026-01-01T00:01:00Z",
526 streaming: false,
527 },
528 {
529 id: "a2",
530 role: "assistant",
531 createdAt: "2026-01-01T00:01:20Z",
532 updatedAt: "2026-01-01T00:01:20Z",
533 streaming: false,
534 },
535 ]);
536
537 expect(result).toEqual(
538 new Map([
539 ["u1", "2026-01-01T00:00:00Z"],
540 ["a1", "2026-01-01T00:00:00Z"],
541 ["u2", "2026-01-01T00:01:00Z"],
542 ["a2", "2026-01-01T00:01:00Z"],
543 ]),
544 );
545 });
546
547 it("handles system messages without affecting the boundary", () => {
548 const result = computeMessageDurationStart([
549 {
550 id: "u1",
551 role: "user",
552 createdAt: "2026-01-01T00:00:00Z",
553 updatedAt: "2026-01-01T00:00:00Z",
554 streaming: false,
555 },
556 {
557 id: "s1",
558 role: "system",
559 createdAt: "2026-01-01T00:00:01Z",
560 updatedAt: "2026-01-01T00:00:01Z",
561 streaming: false,
562 },
563 {
564 id: "a1",
565 role: "assistant",
566 createdAt: "2026-01-01T00:00:30Z",
567 updatedAt: "2026-01-01T00:00:30Z",
568 streaming: false,
569 },
570 ]);
571
572 expect(result).toEqual(
573 new Map([
574 ["u1", "2026-01-01T00:00:00Z"],
575 ["s1", "2026-01-01T00:00:00Z"],
576 ["a1", "2026-01-01T00:00:00Z"],
577 ]),
578 );
579 });
580
581 it("returns empty map for empty input", () => {
582 expect(computeMessageDurationStart([])).toEqual(new Map());
583 });
584});
585
586describe("normalizeCompactToolLabel", () => {
587 it("removes trailing completion wording from command labels", () => {
588 expect(normalizeCompactToolLabel("Ran command complete")).toBe("Ran command");
589 });
590
591 it("removes trailing completion wording from other labels", () => {
592 expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
593 });
594});
595
596describe("resolveAssistantMessageCopyState", () => {
597 it("returns enabled copy state for completed assistant messages", () => {
598 expect(
599 resolveAssistantMessageCopyState({
600 showCopyButton: true,
601 text: "Ship it",
602 streaming: false,
603 }),
604 ).toEqual({
605 text: "Ship it",
606 visible: true,
607 });
608 });
609
610 it("hides copy while an assistant message is still streaming", () => {
611 expect(
612 resolveAssistantMessageCopyState({
613 showCopyButton: true,
614 text: "Still streaming",
615 streaming: true,
616 }),
617 ).toEqual({
618 text: "Still streaming",
619 visible: false,
620 });
621 });
622
623 it("hides copy for empty completed assistant messages", () => {
624 expect(
625 resolveAssistantMessageCopyState({
626 showCopyButton: true,
627 text: " ",
628 streaming: false,
629 }),
630 ).toEqual({
631 text: null,
632 visible: false,
633 });
634 });
635
636 it("hides copy for non-terminal assistant messages", () => {
637 expect(
638 resolveAssistantMessageCopyState({
639 showCopyButton: false,
640 text: "Interim thought",
641 streaming: false,
642 }),
643 ).toEqual({
644 text: "Interim thought",
645 visible: false,
646 });
647 });
648
649 it("copies the rendered representation of Codex directives", () => {
650 expect(
651 resolveAssistantMessageCopyState({
652 showCopyButton: true,
653 text: [
654 'Created :codex-file-citation{path="outputs/report.xlsx" purpose="output"}.',
655 "",
656 '::artifact-template{skill_name="artifact-template-hello-world" skill_directory="/Users/test/.codex/skills/artifact-template-hello-world" display_name="Hello World" artifact_kind="document"}',
657 ].join("\n"),
658 streaming: false,
659 }),
660 ).toEqual({
661 text: "Created [report.xlsx](<outputs/report.xlsx>).\n\nHello World (Document template)",
662 visible: true,
663 });
664 });
665});
666
667describe("deriveMessagesTimelineRows", () => {
668 it("stops stranded thinking after a steer and follows the next thought or tool", () => {
669 const runId = RunId.make("steered-run");
670 const at = "2026-10-01T06:19:10Z";
671 const thought = (id: string): TimelineEntry => ({
672 kind: "work",
673 id,
674 createdAt: at,
675 entry: {
676 id,
677 runId,
678 createdAt: at,
679 label: "Thinking",
680 tone: "thinking",
681 itemType: "reasoning",
682 detail: id,
683 toolLifecycleStatus: "inProgress",
684 },
685 });
686 const steer: TimelineEntry = {
687 kind: "message",
688 id: "steer",
689 createdAt: at,
690 message: {
691 id: MessageId.make("steer"),
692 role: "user",
693 text: "Also evaluate speed",
694 runId,
695 inputIntent: "steer",
696 streaming: false,
697 createdAt: at,
698 updatedAt: at,
699 },
700 };
701 const first = thought("first-thought");
702 const next = thought("next-thought");
703 const tool: TimelineEntry = {
704 kind: "work",
705 id: "tool",
706 createdAt: at,
707 entry: {
708 id: "tool",
709 runId,
710 createdAt: at,
711 label: "Running cat",
712 tone: "tool",
713 command: "cat file",
714 toolLifecycleStatus: "inProgress",
715 },
716 };
717 const rows = (timelineEntries: ReadonlyArray<TimelineEntry>) =>
718 deriveMessagesTimelineRows({
719 timelineEntries,
720 runningRunId: runId,
721 isWorking: true,
722 activeTurnStartedAt: at,
723 turnDiffSummaries: [],
724 supportsConversationRollback: false,
725 });
726 expect(rows([first]).find((row) => row.kind === "work-live")).toMatchObject({
727 active: true,
728 entry: { id: "first-thought" },
729 });
730 const afterSteer = rows([first, steer]);
731 expect(afterSteer.some((row) => row.kind === "work-live")).toBe(false);
732 expect(afterSteer.at(-1)?.kind).toBe("thinking");
733 expect(afterSteer.find((row) => row.kind === "work")).toMatchObject({
734 groupedEntries: [{ id: "first-thought", toolLifecycleStatus: "completed" }],
735 });
736 for (const entries of [
737 [first, steer, next],
738 [first, next],
739 [first, steer, next, tool],
740 ]) {
741 const live = rows(entries).filter((row) => row.kind === "work-live" && row.active);
742 expect(live).toHaveLength(1);
743 expect(live[0]).toMatchObject({ entry: { id: entries.at(-1)!.id } });
744 }
745 // Presentation must not rewrite the retained provider lifecycle.
746 expect(first.kind === "work" && first.entry.toolLifecycleStatus).toBe("inProgress");
747 });
748
749 it.each(["cua_repl.js", "node_repl.js", "js"])(
750 "shows the %s action title for a retained tool item",
751 (toolName) => {
752 const fixture = makeStreamingTimelineFixture();
753 const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!;
754 if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture");
755 const item = {
756 ...source.item,
757 title: null,
758 toolName,
759 input: {
760 code: "await game.getAXStateAndScreenshot();",
761 title: "Inspect Saga music screen",
762 },
763 };
764 const entries = deriveTimelineEntriesFromVisibleTurnItems({
765 visibleTurnItems: [{ ...source, item }],
766 optimisticMessages: [],
767 });
768 const work = entries.find((entry) => entry.kind === "work");
769 expect(work?.kind === "work" && workEntryDisplayLabel(work.entry, undefined)).toBe(
770 "Inspect Saga music screen",
771 );
772 const rows = deriveMessagesTimelineRows({
773 timelineEntries: entries,
774 isWorking: false,
775 runningRunId: fixture.runId,
776 activeTurnStartedAt: fixture.time(0),
777 turnDiffSummaries: [],
778 supportsConversationRollback: false,
779 });
780 expect(rows.find((row) => row.kind === "work")).toMatchObject({
781 displayLabel: "Inspect Saga music screen",
782 });
783 },
784 );
785
786 it("recovers the failed Sky call stored as a generic js integration", () => {
787 const fixture = makeStreamingTimelineFixture();
788 const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!;
789 if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture");
790 const item = {
791 ...source.item,
792 type: "dynamic_tool" as const,
793 status: "failed" as const,
794 title: "js",
795 toolName: "node_repl.js",
796 toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" as const },
797 input: {
798 title: "Restrict the breakpoint to Git actions",
799 code: "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});",
800 },
801 };
802 const entries = deriveTimelineEntriesFromVisibleTurnItems({
803 visibleTurnItems: [{ ...source, item }],
804 optimisticMessages: [],
805 });
806 const entry = entries.find((row) => row.kind === "work");
807 expect(entry?.kind === "work" && workEntryDisplayLabel(entry.entry, undefined)).toBe(
808 "Restrict the breakpoint to Git actions",
809 );
810 expect(entry?.kind === "work" && entry.entry).toMatchObject({
811 toolSurface: "computer",
812 toolSource: { key: "computer-use", kind: "computer" },
813 });
814 });
815
816 it("presents project MCP calls and summarizes successful clones through the web timeline", () => {
817 const fixture = makeStreamingTimelineFixture();
818 const source = fixture.visibleTurnItems.find((row) => row.item.type === "dynamic_tool")!;
819 if (source.item.type !== "dynamic_tool") throw new Error("Expected tool fixture");
820 const items: OrchestrationV2ProjectedTurnItem["item"][] = [
821 {
822 ...source.item,
823 type: "dynamic_tool",
824 id: TurnItemId.make("list"),
825 status: "completed",
826 title: "Custom provider title",
827 toolName: "T3-code.t3_project_list",
828 input: {},
829 output: { projects: [] },
830 },
831 {
832 ...source.item,
833 type: "dynamic_tool",
834 id: TurnItemId.make("clone"),
835 status: "completed",
836 title: "Custom provider title",
837 toolName: "mcp__t3_code__t3_project_clone",
838 input: {},
839 output: { cwd: "/tmp/repo" },
840 },
841 {
842 ...source.item,
843 type: "dynamic_tool",
844 id: TurnItemId.make("failed-clone"),
845 status: "completed",
846 title: "Custom provider title",
847 toolName: "t3_project_clone",
848 input: {},
849 output: { isError: true },
850 },
851 ];
852 const entries = deriveTimelineEntriesFromVisibleTurnItems({
853 visibleTurnItems: items.map((item, position) => ({
854 ...source,
855 item,
856 position,
857 sourceItemId: item.id,
858 })),
859 optimisticMessages: [],
860 });
861 const work = entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : []));
862 expect(workEntryDisplayLabel(work[0]!, undefined)).toBe("Listed projects");
863 expect(workEntryDisplayLabel(work[1]!, undefined)).toBe("Cloned a repository");
864 expect(workEntryDisplayLabel(work[2]!, undefined)).toBe("Failed to clone a repository");
865 expect(
866 resolveTimelineToolPresentation(items[1]!.type === "dynamic_tool" ? items[1].toolName : null)
867 ?.logo,
868 ).toBe("t3-code");
869 const rows = deriveMessagesTimelineRows({
870 timelineEntries: entries,
871 isWorking: false,
872 runningRunId: fixture.runId,
873 activeTurnStartedAt: fixture.time(0),
874 turnDiffSummaries: [],
875 supportsConversationRollback: false,
876 });
877 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
878 summary: "Listed projects 1 time and cloned 1 repository",
879 hasFailure: true,
880 });
881 });
882
883 it.each(["waiting", "completed"] as const)(
884 "groups approval and user-input requests with commands without expanding them when %s",
885 (status) => {
886 const fixture = makeStreamingTimelineFixture();
887 const source = fixture.visibleTurnItems[0]!;
888 const common = {
889 ...source.item,
890 threadId: fixture.threadId,
891 runId: fixture.runId,
892 nodeId: null,
893 providerThreadId: null,
894 providerTurnId: null,
895 nativeItemRef: null,
896 parentItemId: null,
897 status,
898 };
899 const items: OrchestrationV2ProjectedTurnItem["item"][] = [
900 {
901 ...common,
902 id: TurnItemId.make("command"),
903 type: "command_execution",
904 input: "pwd",
905 output: "",
906 },
907 {
908 ...common,
909 id: TurnItemId.make("approval"),
910 type: "approval_request",
911 requestId: RuntimeRequestId.make("approval-request"),
912 requestKind: "command",
913 prompt: "Allow pwd?",
914 },
915 {
916 ...common,
917 id: TurnItemId.make("input"),
918 type: "user_input_request",
919 requestId: RuntimeRequestId.make("input-request"),
920 questions: [],
921 },
922 ];
923 const entries = deriveTimelineEntriesFromVisibleTurnItems({
924 visibleTurnItems: items.map((item, position) => ({
925 ...source,
926 item,
927 position,
928 sourceItemId: item.id,
929 })),
930 optimisticMessages: [],
931 });
932 expect(entries.map((entry) => entry.kind)).toEqual(["work", "work", "work"]);
933 const rows = deriveMessagesTimelineRows({
934 timelineEntries: entries,
935 isWorking: status === "waiting",
936 runningRunId: fixture.runId,
937 activeTurnStartedAt: fixture.time(0),
938 turnDiffSummaries: [],
939 supportsConversationRollback: false,
940 });
941 const workRows = rows.filter((row) => row.kind === "work-toggle" || row.kind === "work-live");
942 expect(workRows).toHaveLength(1);
943 expect(workRows[0]).toMatchObject({ expanded: false });
944 const expandedRows = deriveMessagesTimelineRows({
945 timelineEntries: entries,
946 isWorking: status === "waiting",
947 runningRunId: fixture.runId,
948 activeTurnStartedAt: fixture.time(0),
949 turnDiffSummaries: [],
950 supportsConversationRollback: false,
951 expandedWorkGroupIds: new Set([workRows[0]!.groupId]),
952 });
953 const expanded = expandedRows.find((row) => row.kind === "work" && row.isExpandedToolGroup);
954 expect(
955 expanded?.kind === "work" && expanded.groupedEntries.map((entry) => entry.itemType),
956 ).toEqual(["command_execution", "approval_request", "user_input_request"]);
957 expect(rows.some((row) => row.kind === "event")).toBe(false);
958 expect(workRows[0]).not.toMatchObject({ expanded: true });
959 expect(workRows[0]).not.toMatchObject({ isExpandedToolGroup: true });
960 },
961 );
962
963 it.each(["running", "completed", "superseded"] as const)(
964 "keeps system notices visible as non-failing warnings through %s work",
965 (state) => {
966 const fixture = makeStreamingTimelineFixture();
967 const message =
968 "Claude changed its safety mode.\nReview the active permission settings before continuing.";
969 const source: OrchestrationV2ProjectedTurnItem = {
970 ...fixture.visibleTurnItems[0]!,
971 sourceItemId: TurnItemId.make("safety-notice"),
972 item: {
973 id: TurnItemId.make("safety-notice"),
974 threadId: fixture.threadId,
975 runId: fixture.runId,
976 nodeId: null,
977 providerThreadId: null,
978 providerTurnId: null,
979 nativeItemRef: null,
980 parentItemId: null,
981 ordinal: 1,
982 startedAt: DateTime.makeUnsafe(fixture.time(1)),
983 completedAt: DateTime.makeUnsafe(fixture.time(1)),
984 updatedAt: DateTime.makeUnsafe(fixture.time(1)),
985 type: "system_notice",
986 message,
987 title: message,
988 status: "completed",
989 },
990 };
991 const [notice] = deriveTimelineEntriesFromVisibleTurnItems({
992 visibleTurnItems: [source],
993 optimisticMessages: [],
994 });
995 if (notice?.kind !== "work") throw new Error("Expected a notice work entry");
996 expect(notice.entry.projectedItem).toBe(source);
997 expect(notice.entry.sourceActivityKind).toBe("runtime.warning");
998 expect(workEntryDisplayLabel(notice.entry, undefined)).toBe(message);
999 expect(workEntryDisplayIndicatesToolFailure(notice.entry)).toBe(false);
1000 const rows = deriveMessagesTimelineRows({
1001 timelineEntries: [
1002 {
1003 ...notice,
1004 ...(state === "superseded"
1005 ? {
1006 attempt: {
1007 id: "old-attempt" as never,
1008 runId: fixture.runId,
1009 status: "superseded" as const,
1010 attemptOrdinal: 0,
1011 rootNodeId: "old-root" as never,
1012 completedAt: null,
1013 },
1014 }
1015 : {}),
1016 },
1017 {
1018 id: "nearby-tool",
1019 kind: "work",
1020 createdAt: notice.createdAt,
1021 entry: {
1022 id: "nearby-tool",
1023 runId: fixture.runId,
1024 createdAt: notice.createdAt,
1025 label: "Ran command",
1026 tone: "tool",
1027 toolLifecycleStatus: "completed",
1028 },
1029 },
1030 {
1031 id: "terminal-message",
1032 kind: "message",
1033 createdAt: notice.createdAt,
1034 message: {
1035 id: MessageId.make("terminal-message"),
1036 runId: fixture.runId,
1037 role: "assistant",
1038 text: "Continuing with the selected settings.",
1039 createdAt: notice.createdAt,
1040 updatedAt: notice.createdAt,
1041 streaming: state === "running",
1042 },
1043 },
1044 ],
1045 isWorking: state === "running",
1046 activeTurnStartedAt: notice.createdAt,
1047 latestRun: {
1048 runId: fixture.runId,
1049 status: state === "running" ? "running" : "completed",
1050 startedAt: notice.createdAt,
1051 completedAt: state === "running" ? null : notice.createdAt,
1052 },
1053 turnDiffSummaries: [],
1054 supportsConversationRollback: false,
1055 });
1056 const row = rows.find(
1057 (row) => row.kind === "work" && row.groupedEntries.includes(notice.entry),
1058 );
1059 expect(row).toMatchObject({ kind: "work", groupedEntries: [notice.entry] });
1060 },
1061 );
1062
1063 it("keeps context compaction visible outside folded work", () => {
1064 const rows = deriveMessagesTimelineRows({
1065 timelineEntries: [
1066 {
1067 id: "compaction-entry",
1068 kind: "work",
1069 createdAt: "2026-01-01T00:00:00Z",
1070 entry: {
1071 id: "compaction",
1072 createdAt: "2026-01-01T00:00:00Z",
1073 label: "Compacted context 899K → 19K tokens",
1074 tone: "info",
1075 sourceActivityKind: "context-compaction",
1076 },
1077 },
1078 ],
1079 isWorking: false,
1080 activeTurnStartedAt: null,
1081 turnDiffSummaries: [],
1082 supportsConversationRollback: false,
1083 });
1084
1085 expect(rows).toEqual([
1086 {
1087 kind: "context-compaction",
1088 id: "compaction-entry",
1089 createdAt: "2026-01-01T00:00:00Z",
1090 label: "Compacted context 899K → 19K tokens",
1091 active: false,
1092 },
1093 ]);
1094 });
1095
1096 it("keeps mid-turn compaction in the activity slot and restores Thinking when it completes", () => {
1097 const fixture = makeStreamingTimelineFixture();
1098 const last = fixture.visibleTurnItems.at(-1)!;
1099 if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1100 const compact = {
1101 ...last,
1102 item: {
1103 ...last.item,
1104 type: "compaction" as const,
1105 status: "running" as const,
1106 driver: null,
1107 beforeTokenCount: 899_000,
1108 },
1109 };
1110 const input = {
1111 isWorking: true,
1112 runningRunId: fixture.runId,
1113 activeTurnStartedAt: fixture.time(5),
1114 turnDiffSummaries: [],
1115 supportsConversationRollback: false,
1116 };
1117 const runningEntries = deriveTimelineEntriesFromVisibleTurnItems({
1118 visibleTurnItems: [...fixture.visibleTurnItems.slice(0, -1), compact],
1119 optimisticMessages: [],
1120 });
1121 const runningRows = deriveMessagesTimelineRows({ ...input, timelineEntries: runningEntries });
1122 expect(runningRows.find((row) => row.kind === "context-compaction")).toMatchObject({
1123 active: true,
1124 label: "Compacting context",
1125 });
1126 expect(runningRows.some((row) => row.kind === "thinking")).toBe(false);
1127
1128 const completedRows = deriveMessagesTimelineRows({
1129 ...input,
1130 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1131 visibleTurnItems: [
1132 ...fixture.visibleTurnItems.slice(0, -1),
1133 {
1134 ...compact,
1135 item: { ...compact.item, status: "completed", afterTokenCount: 19_000 },
1136 },
1137 ],
1138 optimisticMessages: [],
1139 }),
1140 });
1141 expect(completedRows.find((row) => row.kind === "context-compaction")).toMatchObject({
1142 active: false,
1143 label: "Context compacted 899K → 19K tokens",
1144 });
1145 expect(completedRows.at(-1)?.kind).toBe("thinking");
1146
1147 const settledRows = deriveMessagesTimelineRows({
1148 ...input,
1149 isWorking: false,
1150 timelineEntries: runningEntries,
1151 });
1152 expect(settledRows.find((row) => row.kind === "context-compaction")).toMatchObject({
1153 active: false,
1154 });
1155 const stable = computeStableMessagesTimelineRows(runningRows, { result: [], byId: new Map() });
1156 expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows);
1157 });
1158
1159 it("uses the initial compaction's own clock in the turn header, then remembers completion", () => {
1160 const fixture = makeStreamingTimelineFixture();
1161 const source = fixture.visibleTurnItems.at(-1)!;
1162 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1163 const compaction = {
1164 ...source,
1165 item: {
1166 ...source.item,
1167 type: "compaction" as const,
1168 driver: null,
1169 status: "running" as const,
1170 startedAt: DateTime.makeUnsafe(fixture.time(9)),
1171 completedAt: null,
1172 },
1173 };
1174 const entries = (status: "running" | "completed") =>
1175 deriveTimelineEntriesFromVisibleTurnItems({
1176 visibleTurnItems: [
1177 ...fixture.visibleTurnItems.slice(0, 4),
1178 { ...compaction, item: { ...compaction.item, status } },
1179 ],
1180 optimisticMessages: [],
1181 });
1182 const input = {
1183 isWorking: true,
1184 runningRunId: fixture.runId,
1185 activeTurnStartedAt: fixture.time(5),
1186 turnDiffSummaries: [],
1187 supportsConversationRollback: false,
1188 };
1189 const runningRows = deriveMessagesTimelineRows({
1190 ...input,
1191 timelineEntries: entries("running"),
1192 });
1193 expect(runningRows.find((row) => row.kind === "working")).toMatchObject({
1194 createdAt: fixture.time(5),
1195 compaction: { createdAt: fixture.time(9), toolLifecycleStatus: "inProgress" },
1196 });
1197 expect(runningRows.some((row) => row.kind === "context-compaction")).toBe(false);
1198 expect(runningRows.some((row) => row.kind === "thinking")).toBe(false);
1199
1200 const completedRows = deriveMessagesTimelineRows({
1201 ...input,
1202 timelineEntries: entries("completed"),
1203 });
1204 expect(completedRows.find((row) => row.kind === "working")).toMatchObject({
1205 createdAt: fixture.time(5),
1206 compaction: { toolLifecycleStatus: "completed" },
1207 });
1208 expect(completedRows.some((row) => row.kind === "context-compaction")).toBe(false);
1209 expect(completedRows.at(-1)?.kind).toBe("thinking");
1210 const stable = computeStableMessagesTimelineRows(runningRows, { result: [], byId: new Map() });
1211 expect(computeStableMessagesTimelineRows(completedRows, stable).result).toEqual(completedRows);
1212 });
1213
1214 it.each([false, true])(
1215 "keeps initial compaction in the completed turn summary when expanded=%s",
1216 (expanded) => {
1217 const fixture = makeStreamingTimelineFixture("Done");
1218 const source = fixture.visibleTurnItems.at(-1)!;
1219 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1220 const compaction = {
1221 ...source,
1222 sourceItemId: TurnItemId.make("initial-compaction"),
1223 item: {
1224 ...source.item,
1225 id: TurnItemId.make("initial-compaction"),
1226 type: "compaction" as const,
1227 driver: null,
1228 status: "completed" as const,
1229 startedAt: DateTime.makeUnsafe(fixture.time(6)),
1230 completedAt: DateTime.makeUnsafe(fixture.time(7)),
1231 },
1232 };
1233 const rows = deriveMessagesTimelineRows({
1234 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1235 visibleTurnItems: [
1236 ...fixture.visibleTurnItems.slice(0, 4),
1237 compaction,
1238 { ...source, item: { ...source.item, streaming: false, status: "completed" } },
1239 ],
1240 optimisticMessages: [],
1241 }),
1242 isWorking: false,
1243 latestRun: {
1244 runId: fixture.runId,
1245 status: "completed",
1246 startedAt: fixture.time(5),
1247 completedAt: fixture.time(27),
1248 },
1249 expandedRunIds: expanded ? new Set([fixture.runId]) : new Set(),
1250 turnDiffSummaries: [],
1251 supportsConversationRollback: false,
1252 });
1253 expect(
1254 rows.find((row) => row.kind === "turn-fold" && row.runId === fixture.runId),
1255 ).toMatchObject({
1256 label: "Worked for 22s (context compacted)",
1257 expanded,
1258 summaryOnly: true,
1259 });
1260 expect(rows.some((row) => row.kind === "context-compaction")).toBe(false);
1261 expect(rows.some((row) => row.kind === "message" && row.message.text === "Done")).toBe(true);
1262 },
1263 );
1264
1265 it.each(["latest run unavailable", "later run selected"] as const)(
1266 "uses recorded compaction completion for a historical summary when %s",
1267 (latest) => {
1268 const fixture = makeStreamingTimelineFixture();
1269 const source = fixture.visibleTurnItems.at(-1)!;
1270 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1271 const compaction = {
1272 ...source,
1273 item: {
1274 ...source.item,
1275 type: "compaction" as const,
1276 driver: null,
1277 runId: fixture.historyRunId,
1278 status: "completed" as const,
1279 startedAt: DateTime.makeUnsafe(fixture.time(1)),
1280 completedAt: DateTime.makeUnsafe(fixture.time(83)),
1281 updatedAt: DateTime.makeUnsafe(fixture.time(83)),
1282 },
1283 };
1284 const rows = deriveMessagesTimelineRows({
1285 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1286 visibleTurnItems: [fixture.visibleTurnItems[0]!, compaction],
1287 optimisticMessages: [],
1288 }),
1289 isWorking: false,
1290 latestRun:
1291 latest === "later run selected"
1292 ? {
1293 runId: fixture.runId,
1294 status: "completed",
1295 startedAt: fixture.time(100),
1296 completedAt: fixture.time(105),
1297 }
1298 : null,
1299 turnDiffSummaries: [],
1300 supportsConversationRollback: false,
1301 });
1302 expect(rows.find((row) => row.kind === "turn-fold")).toMatchObject({
1303 label: "Worked for 1m 23s (context compacted)",
1304 summaryOnly: true,
1305 });
1306 },
1307 );
1308
1309 it.each(
1310 (["failed", "cancelled", "interrupted"] as const).flatMap((status) =>
1311 [true, false].map((isWorking) => ({ status, isWorking })),
1312 ),
1313 )(
1314 "retains a visible initial compaction result when $status and working=$isWorking",
1315 ({ status, isWorking }) => {
1316 const fixture = makeStreamingTimelineFixture("Done");
1317 const source = fixture.visibleTurnItems.at(-1)!;
1318 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1319 const rows = deriveMessagesTimelineRows({
1320 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1321 visibleTurnItems: [
1322 ...fixture.visibleTurnItems.slice(0, 4),
1323 {
1324 ...source,
1325 sourceItemId: TurnItemId.make("failed-compaction"),
1326 item: {
1327 ...source.item,
1328 id: TurnItemId.make("failed-compaction"),
1329 type: "compaction",
1330 driver: null,
1331 status,
1332 },
1333 },
1334 fixture.visibleTurnItems[4]!,
1335 { ...source, item: { ...source.item, streaming: isWorking } },
1336 ],
1337 optimisticMessages: [],
1338 }),
1339 isWorking,
1340 runningRunId: isWorking ? fixture.runId : null,
1341 turnDiffSummaries: [],
1342 supportsConversationRollback: false,
1343 });
1344 expect(rows.find((row) => row.kind === "context-compaction")).toMatchObject({
1345 active: false,
1346 label: status === "failed" ? "Context compaction failed" : "Context compaction stopped",
1347 });
1348 if (isWorking) {
1349 expect(rows.find((row) => row.kind === "working")).not.toHaveProperty("compaction");
1350 }
1351 },
1352 );
1353
1354 it("only enables assistant copy for the terminal assistant message in a turn", () => {
1355 const rows = deriveMessagesTimelineRows({
1356 timelineEntries: [
1357 {
1358 id: "user-1-entry",
1359 kind: "message",
1360 createdAt: "2026-01-01T00:00:00Z",
1361 message: {
1362 id: "user-1" as never,
1363 role: "user",
1364 text: "Write a poem",
1365 runId: null,
1366 createdAt: "2026-01-01T00:00:00Z",
1367 updatedAt: "2026-01-01T00:00:00Z",
1368 streaming: false,
1369 },
1370 },
1371 {
1372 id: "assistant-thought-entry",
1373 kind: "message",
1374 createdAt: "2026-01-01T00:00:10Z",
1375 message: {
1376 id: "assistant-thought" as never,
1377 role: "assistant",
1378 text: "I should ground this first.",
1379 runId: "turn-1" as never,
1380 createdAt: "2026-01-01T00:00:10Z",
1381 updatedAt: "2026-01-01T00:00:11Z",
1382 streaming: false,
1383 },
1384 },
1385 {
1386 id: "assistant-final-entry",
1387 kind: "message",
1388 createdAt: "2026-01-01T00:00:20Z",
1389 message: {
1390 id: "assistant-final" as never,
1391 role: "assistant",
1392 text: "Here is the poem.",
1393 runId: "turn-1" as never,
1394 createdAt: "2026-01-01T00:00:20Z",
1395 updatedAt: "2026-01-01T00:00:30Z",
1396 streaming: false,
1397 },
1398 },
1399 ],
1400 expandedRunIds: new Set(["turn-1" as never]),
1401 isWorking: false,
1402 activeTurnStartedAt: null,
1403 turnDiffSummaries: [],
1404 supportsConversationRollback: false,
1405 });
1406
1407 const assistantRows = rows.filter(
1408 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
1409 row.kind === "message" && row.message.role === "assistant",
1410 );
1411
1412 expect(assistantRows).toHaveLength(2);
1413 expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);
1414 expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
1415 });
1416
1417 it("marks only the active assistant turn as streaming for copy controls", () => {
1418 const rows = deriveMessagesTimelineRows({
1419 timelineEntries: [
1420 {
1421 id: "assistant-one-entry",
1422 kind: "message",
1423 createdAt: "2026-01-01T00:00:10Z",
1424 message: {
1425 id: "assistant-one" as never,
1426 role: "assistant",
1427 text: "Earlier response.",
1428 runId: "turn-1" as never,
1429 createdAt: "2026-01-01T00:00:10Z",
1430 updatedAt: "2026-01-01T00:00:11Z",
1431 streaming: false,
1432 },
1433 },
1434 {
1435 id: "assistant-two-entry",
1436 kind: "message",
1437 createdAt: "2026-01-01T00:00:20Z",
1438 message: {
1439 id: "assistant-two" as never,
1440 role: "assistant",
1441 text: "Active response.",
1442 runId: "turn-2" as never,
1443 createdAt: "2026-01-01T00:00:20Z",
1444 updatedAt: "2026-01-01T00:00:30Z",
1445 streaming: false,
1446 },
1447 },
1448 ],
1449 latestRun: {
1450 runId: "turn-2" as never,
1451 status: "running",
1452 startedAt: "2026-01-01T00:00:19Z",
1453 completedAt: null,
1454 },
1455 isWorking: false,
1456 activeTurnStartedAt: null,
1457 turnDiffSummaries: [],
1458 supportsConversationRollback: false,
1459 });
1460
1461 const assistantRows = rows.filter(
1462 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
1463 row.kind === "message" && row.message.role === "assistant",
1464 );
1465
1466 expect(assistantRows[0]?.assistantCopyStreaming).toBe(false);
1467 expect(assistantRows[1]?.assistantCopyStreaming).toBe(true);
1468 });
1469
1470 it("projects assistant diff summaries and user revert counts onto the affected rows", () => {
1471 const assistantTurnDiffSummary = {
1472 runId: "turn-1" as never,
1473 completedAt: "2026-01-01T00:00:30Z",
1474 assistantMessageId: "assistant-1" as never,
1475 checkpointTurnCount: 2,
1476 checkpointRef: "checkpoint-1" as never,
1477 status: "ready" as const,
1478 files: [{ path: "src/index.ts", kind: "modified", additions: 3, deletions: 1 }],
1479 };
1480
1481 const rows = deriveMessagesTimelineRows({
1482 timelineEntries: [
1483 {
1484 id: "user-entry",
1485 kind: "message",
1486 createdAt: "2026-01-01T00:00:00Z",
1487 message: {
1488 id: "user-1" as never,
1489 role: "user",
1490 text: "Do the thing",
1491 runId: "turn-1" as never,
1492 inputIntent: "turn_start",
1493 createdAt: "2026-01-01T00:00:00Z",
1494 updatedAt: "2026-01-01T00:00:00Z",
1495 streaming: false,
1496 },
1497 },
1498 {
1499 id: "assistant-entry",
1500 kind: "message",
1501 createdAt: "2026-01-01T00:00:20Z",
1502 message: {
1503 id: "assistant-1" as never,
1504 role: "assistant",
1505 text: "Done",
1506 runId: "turn-1" as never,
1507 createdAt: "2026-01-01T00:00:20Z",
1508 updatedAt: "2026-01-01T00:00:30Z",
1509 streaming: false,
1510 },
1511 },
1512 ],
1513 isWorking: false,
1514 activeTurnStartedAt: null,
1515 turnDiffSummaries: [assistantTurnDiffSummary],
1516 supportsConversationRollback: true,
1517 });
1518
1519 const userRow = rows.find(
1520 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
1521 row.kind === "message" && row.message.role === "user",
1522 );
1523 const assistantRow = rows.find(
1524 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
1525 row.kind === "message" && row.message.role === "assistant",
1526 );
1527
1528 expect(userRow?.revertTurnCount).toBe(1);
1529 expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
1530 });
1531
1532 it("folds the first assistant message and settled work before the terminal response", () => {
1533 const timelineEntries = [
1534 {
1535 id: "user-entry",
1536 kind: "message" as const,
1537 createdAt: "2026-01-01T00:00:00Z",
1538 message: {
1539 id: "user-1" as never,
1540 role: "user" as const,
1541 text: "Build it",
1542 runId: null,
1543 createdAt: "2026-01-01T00:00:00Z",
1544 updatedAt: "2026-01-01T00:00:00Z",
1545 streaming: false,
1546 },
1547 },
1548 {
1549 id: "assistant-first-entry",
1550 kind: "message" as const,
1551 createdAt: "2026-01-01T00:00:05Z",
1552 message: {
1553 id: "assistant-first" as never,
1554 role: "assistant" as const,
1555 text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.",
1556 runId: "turn-1" as never,
1557 createdAt: "2026-01-01T00:00:05Z",
1558 updatedAt: "2026-01-01T00:00:06Z",
1559 streaming: false,
1560 },
1561 },
1562 {
1563 id: "work-entry-1",
1564 kind: "work" as const,
1565 createdAt: "2026-01-01T00:00:08Z",
1566 entry: {
1567 id: "work-1",
1568 createdAt: "2026-01-01T00:00:08Z",
1569 runId: "turn-1" as never,
1570 label: "Ran command",
1571 tone: "tool" as const,
1572 },
1573 },
1574 {
1575 id: "assistant-final-entry",
1576 kind: "message" as const,
1577 createdAt: "2026-01-01T00:00:20Z",
1578 message: {
1579 id: "assistant-final" as never,
1580 role: "assistant" as const,
1581 text: "Done",
1582 runId: "turn-1" as never,
1583 createdAt: "2026-01-01T00:00:20Z",
1584 updatedAt: "2026-01-01T00:00:22Z",
1585 streaming: false,
1586 },
1587 },
1588 ];
1589
1590 const collapsedRows = deriveMessagesTimelineRows({
1591 timelineEntries,
1592 isWorking: false,
1593 activeTurnStartedAt: null,
1594 turnDiffSummaries: [],
1595 supportsConversationRollback: false,
1596 });
1597
1598 const foldRow = collapsedRows.find(
1599 (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> =>
1600 row.kind === "turn-fold",
1601 );
1602 expect(foldRow?.runId).toBe("turn-1");
1603 expect(foldRow?.expanded).toBe(false);
1604 // User message boundary (00:00:00) → terminal message updatedAt (00:00:22).
1605 expect(foldRow?.label).toBe("Worked for 22s");
1606 expect(collapsedRows.map((row) => row.id)).toEqual([
1607 "user-entry",
1608 "turn-fold:turn-1",
1609 "assistant-final-entry",
1610 ]);
1611
1612 const expandedRows = deriveMessagesTimelineRows({
1613 timelineEntries,
1614 expandedRunIds: new Set(["turn-1" as never]),
1615 isWorking: false,
1616 activeTurnStartedAt: null,
1617 turnDiffSummaries: [],
1618 supportsConversationRollback: false,
1619 });
1620
1621 expect(expandedRows.map((row) => row.id)).toEqual([
1622 "user-entry",
1623 "turn-fold:turn-1",
1624 "assistant-first-entry",
1625 "work-entry-1",
1626 "assistant-final-entry",
1627 ]);
1628 expect(
1629 expandedRows.find((row) => row.kind === "turn-fold" && row.expanded === true),
1630 ).toBeDefined();
1631 });
1632
1633 it.each([1, 2, 3])("folds %i completed activities after the terminal response", (count) => {
1634 const runId = RunId.make("turn-1");
1635 const timelineEntries = [
1636 {
1637 id: "work-entry-before-text",
1638 kind: "work" as const,
1639 createdAt: "2026-01-01T00:00:01Z",
1640 entry: {
1641 id: "work-before-text",
1642 createdAt: "2026-01-01T00:00:01Z",
1643 runId,
1644 label: "Status updated",
1645 tone: "info" as const,
1646 },
1647 },
1648 {
1649 id: "assistant-final-entry",
1650 kind: "message" as const,
1651 createdAt: "2026-01-01T00:00:05Z",
1652 message: {
1653 id: "assistant-final" as never,
1654 role: "assistant" as const,
1655 text: "CI is re-running. Waiting.",
1656 runId,
1657 createdAt: "2026-01-01T00:00:05Z",
1658 updatedAt: "2026-01-01T00:00:06Z",
1659 streaming: false,
1660 },
1661 },
1662 ...Array.from({ length: count }, (_, index) => ({
1663 id: `work-entry-after-text-${index}`,
1664 kind: "work" as const,
1665 createdAt: `2026-01-01T00:00:0${index + 7}Z`,
1666 entry: {
1667 id: `work-after-text-${index}`,
1668 createdAt: `2026-01-01T00:00:0${index + 7}Z`,
1669 runId,
1670 label: "Ran command",
1671 tone: "tool" as const,
1672 itemType: "command_execution" as const,
1673 toolLifecycleStatus: "completed" as const,
1674 },
1675 })),
1676 ];
1677
1678 const input = {
1679 latestRun: {
1680 runId,
1681 status: "completed" as const,
1682 startedAt: "2026-01-01T00:00:00Z",
1683 completedAt: "2026-01-01T00:00:10Z",
1684 },
1685 isWorking: false,
1686 activeTurnStartedAt: null,
1687 turnDiffSummaries: [],
1688 supportsConversationRollback: false,
1689 };
1690 const rows = deriveMessagesTimelineRows({ ...input, timelineEntries });
1691
1692 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
1693 const expanded = deriveMessagesTimelineRows({
1694 ...input,
1695 timelineEntries,
1696 expandedRunIds: new Set([runId]),
1697 });
1698 expect(
1699 expanded.some(
1700 (row) =>
1701 row.id === "work-entry-after-text-0" ||
1702 (row.kind === "work-toggle" &&
1703 row.id === "work-toggle:work-entry-after-text-0" &&
1704 row.hiddenCount === count),
1705 ),
1706 ).toBe(true);
1707
1708 // A late failure must remain visible even though successful work is folded.
1709 const failedEntries = timelineEntries.map((entry) =>
1710 entry.kind === "work" && entry.id === "work-entry-after-text-0"
1711 ? { ...entry, entry: { ...entry.entry, toolLifecycleStatus: "failed" as const } }
1712 : entry,
1713 );
1714 const failedRows = deriveMessagesTimelineRows({ ...input, timelineEntries: failedEntries });
1715 expect(failedRows.some((row) => row.id === "work-entry-after-text-0")).toBe(true);
1716
1717 const pendingEntries = timelineEntries.map((entry) =>
1718 entry.kind === "work" && entry.id === "work-entry-after-text-0"
1719 ? { ...entry, entry: { ...entry.entry, toolLifecycleStatus: "inProgress" as const } }
1720 : entry,
1721 );
1722 const pendingRows = deriveMessagesTimelineRows({
1723 ...input,
1724 timelineEntries: pendingEntries,
1725 latestRun: { ...input.latestRun, status: "running", completedAt: null },
1726 isWorking: true,
1727 activeTurnStartedAt: input.latestRun.startedAt,
1728 });
1729 expect(
1730 pendingRows.some((row) => row.kind === "work-live" && row.entry.id === "work-after-text-0"),
1731 ).toBe(true);
1732 });
1733
1734 it("folds all assistant messages before the terminal message", () => {
1735 const timelineEntries = [
1736 {
1737 id: "assistant-first-entry",
1738 kind: "message" as const,
1739 createdAt: "2026-01-01T00:00:01Z",
1740 message: {
1741 id: "assistant-first" as never,
1742 role: "assistant" as const,
1743 text: "The main result is ready.",
1744 runId: "turn-1" as never,
1745 createdAt: "2026-01-01T00:00:01Z",
1746 updatedAt: "2026-01-01T00:00:02Z",
1747 streaming: false,
1748 },
1749 },
1750 {
1751 id: "assistant-middle-entry",
1752 kind: "message" as const,
1753 createdAt: "2026-01-01T00:00:03Z",
1754 message: {
1755 id: "assistant-middle" as never,
1756 role: "assistant" as const,
1757 text: "I am checking one more detail.",
1758 runId: "turn-1" as never,
1759 createdAt: "2026-01-01T00:00:03Z",
1760 updatedAt: "2026-01-01T00:00:04Z",
1761 streaming: false,
1762 },
1763 },
1764 {
1765 id: "assistant-final-entry",
1766 kind: "message" as const,
1767 createdAt: "2026-01-01T00:00:05Z",
1768 message: {
1769 id: "assistant-final" as never,
1770 role: "assistant" as const,
1771 text: "Verification finished.",
1772 runId: "turn-1" as never,
1773 createdAt: "2026-01-01T00:00:05Z",
1774 updatedAt: "2026-01-01T00:00:06Z",
1775 streaming: false,
1776 },
1777 },
1778 ];
1779
1780 const rows = deriveMessagesTimelineRows({
1781 timelineEntries,
1782 isWorking: false,
1783 activeTurnStartedAt: null,
1784 turnDiffSummaries: [],
1785 supportsConversationRollback: false,
1786 });
1787
1788 expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]);
1789 });
1790
1791 it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => {
1792 // A steer ends the previous turn early: its only message completes the
1793 // instant it is created, and trailing work entries land after it. The
1794 // fold duration must span from the user message that started the turn to
1795 // the last entry, not message createdAt → message updatedAt (~0ms).
1796 const rows = deriveMessagesTimelineRows({
1797 timelineEntries: [
1798 {
1799 id: "user-entry",
1800 kind: "message",
1801 createdAt: "2026-01-01T00:00:00Z",
1802 message: {
1803 id: "user-1" as never,
1804 role: "user" as const,
1805 text: "do it once more",
1806 runId: null,
1807 createdAt: "2026-01-01T00:00:00Z",
1808 updatedAt: "2026-01-01T00:00:00Z",
1809 streaming: false,
1810 },
1811 },
1812 {
1813 id: "work-entry-before-message",
1814 kind: "work",
1815 createdAt: "2026-01-01T00:00:07Z",
1816 entry: {
1817 id: "work-before-message",
1818 createdAt: "2026-01-01T00:00:07Z",
1819 runId: "turn-1" as never,
1820 label: "Status updated",
1821 tone: "info" as const,
1822 },
1823 },
1824 {
1825 id: "assistant-commentary-entry",
1826 kind: "message",
1827 createdAt: "2026-01-01T00:00:09Z",
1828 message: {
1829 id: "assistant-commentary" as never,
1830 role: "assistant" as const,
1831 text: "Kicking off call 1.",
1832 runId: "turn-1" as never,
1833 createdAt: "2026-01-01T00:00:09Z",
1834 updatedAt: "2026-01-01T00:00:09Z",
1835 streaming: false,
1836 },
1837 },
1838 {
1839 id: "work-entry-1",
1840 kind: "work",
1841 createdAt: "2026-01-01T00:00:12Z",
1842 entry: {
1843 id: "work-1",
1844 createdAt: "2026-01-01T00:00:12Z",
1845 runId: "turn-1" as never,
1846 label: "Ran command",
1847 tone: "tool" as const,
1848 },
1849 },
1850 {
1851 id: "steer-user-entry",
1852 kind: "message",
1853 createdAt: "2026-01-01T00:00:14Z",
1854 message: {
1855 id: "user-2" as never,
1856 role: "user" as const,
1857 text: "actually do 15",
1858 runId: null,
1859 createdAt: "2026-01-01T00:00:14Z",
1860 updatedAt: "2026-01-01T00:00:14Z",
1861 streaming: false,
1862 },
1863 },
1864 {
1865 id: "assistant-next-turn-entry",
1866 kind: "message",
1867 createdAt: "2026-01-01T00:00:17Z",
1868 message: {
1869 id: "assistant-next" as never,
1870 role: "assistant" as const,
1871 text: "One down — adjusting.",
1872 runId: "turn-2" as never,
1873 createdAt: "2026-01-01T00:00:17Z",
1874 updatedAt: "2026-01-01T00:00:17Z",
1875 streaming: true,
1876 },
1877 },
1878 ],
1879 latestRun: {
1880 runId: "turn-2" as never,
1881 status: "running",
1882 startedAt: "2026-01-01T00:00:14Z",
1883 completedAt: null,
1884 },
1885 isWorking: true,
1886 activeTurnStartedAt: "2026-01-01T00:00:14Z",
1887 turnDiffSummaries: [],
1888 supportsConversationRollback: false,
1889 });
1890
1891 const foldRow = rows.find(
1892 (row): row is Extract<(typeof rows)[number], { kind: "turn-fold" }> =>
1893 row.kind === "turn-fold",
1894 );
1895 // User message (00:00:00) → trailing work entry (00:00:12).
1896 expect(foldRow?.runId).toBe("turn-1");
1897 expect(foldRow?.label).toBe("Worked for 12s");
1898 });
1899
1900 it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
1901 const rows = deriveMessagesTimelineRows({
1902 timelineEntries: [
1903 {
1904 id: "work-entry-1",
1905 kind: "work",
1906 createdAt: "2026-01-01T00:00:05Z",
1907 entry: {
1908 id: "work-1",
1909 createdAt: "2026-01-01T00:00:05Z",
1910 runId: "turn-1" as never,
1911 label: "Ran command",
1912 tone: "tool" as const,
1913 },
1914 },
1915 ],
1916 latestRun: {
1917 runId: "turn-1" as never,
1918 status: "interrupted",
1919 startedAt: "2026-01-01T00:00:00Z",
1920 completedAt: "2026-01-01T00:00:47Z",
1921 },
1922 isWorking: false,
1923 activeTurnStartedAt: null,
1924 turnDiffSummaries: [],
1925 supportsConversationRollback: false,
1926 });
1927
1928 expect(rows).toEqual([
1929 expect.objectContaining({
1930 kind: "turn-fold",
1931 runId: "turn-1",
1932 label: "You stopped after 47s",
1933 expanded: false,
1934 }),
1935 ]);
1936 });
1937
1938 it("keeps the working header below the initiating prompt across repeated steers", () => {
1939 const runId = RunId.make("steered-run");
1940 const startedAt = "2026-01-01T00:00:00Z";
1941 const prompt = (id: string, inputIntent: "steer" | "send") => ({
1942 id,
1943 kind: "message" as const,
1944 createdAt: startedAt,
1945 message: {
1946 id: MessageId.make(id),
1947 role: "user" as const,
1948 text: id,
1949 runId,
1950 ...(inputIntent === "steer" ? { inputIntent } : {}),
1951 createdAt: startedAt,
1952 updatedAt: startedAt,
1953 streaming: false,
1954 },
1955 });
1956 const timelineEntries = [prompt("initial-prompt", "send")];
1957 for (let index = 0; index < 3; index += 1) {
1958 const rows = deriveMessagesTimelineRows({
1959 timelineEntries,
1960 latestRun: { runId, status: "running", startedAt, completedAt: null },
1961 isWorking: true,
1962 activeTurnStartedAt: startedAt,
1963 turnDiffSummaries: [],
1964 supportsConversationRollback: false,
1965 });
1966 expect(rows.slice(0, 2).map((row) => row.id)).toEqual([
1967 "initial-prompt",
1968 "working-indicator-row",
1969 ]);
1970 expect(rows.filter((row) => row.kind === "working")).toEqual([
1971 expect.objectContaining({ createdAt: startedAt }),
1972 ]);
1973 expect(rows.filter((row) => row.kind === "message").map((row) => row.id)).toEqual(
1974 timelineEntries.map((entry) => entry.id),
1975 );
1976 timelineEntries.push(prompt(`steer-${index}`, "steer"));
1977 }
1978 });
1979
1980 it.each(["steer", "promoted_queued_to_steer"] as const)(
1981 "keeps the duration header at the initiating prompt when %s arrives before output",
1982 (inputIntent) => {
1983 const runId = RunId.make("steered-run");
1984 const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
1985 const prompt = (id: string, second: number, intent: "turn_start" | typeof inputIntent) => ({
1986 kind: "message" as const,
1987 id,
1988 createdAt: time(second),
1989 message: {
1990 id: MessageId.make(id),
1991 role: "user" as const,
1992 text: id,
1993 runId,
1994 inputIntent: intent,
1995 createdAt: time(second),
1996 updatedAt: time(second),
1997 streaming: false,
1998 },
1999 });
2000 const entries = [prompt("initial-prompt", 0, "turn_start"), prompt("steer", 5, inputIntent)];
2001 const work = {
2002 kind: "work" as const,
2003 id: "work",
2004 createdAt: time(8),
2005 entry: {
2006 id: "work",
2007 createdAt: time(8),
2008 runId,
2009 label: "Ran command",
2010 tone: "tool" as const,
2011 },
2012 };
2013 const final = {
2014 kind: "message" as const,
2015 id: "final",
2016 createdAt: time(20),
2017 message: {
2018 id: MessageId.make("final"),
2019 role: "assistant" as const,
2020 text: "Done",
2021 runId,
2022 createdAt: time(20),
2023 updatedAt: time(20),
2024 streaming: false,
2025 },
2026 };
2027 for (const isWorking of [true, false]) {
2028 for (const expanded of [true, false]) {
2029 const rows = deriveMessagesTimelineRows({
2030 timelineEntries: [...entries, work, final],
2031 latestRun: {
2032 runId,
2033 status: isWorking ? "running" : "completed",
2034 startedAt: time(0),
2035 completedAt: isWorking ? null : time(20),
2036 },
2037 isWorking,
2038 activeTurnStartedAt: time(0),
2039 expandedRunIds: expanded ? new Set([runId]) : new Set(),
2040 turnDiffSummaries: [],
2041 supportsConversationRollback: false,
2042 });
2043 expect(rows.slice(0, 3).map((row) => row.id)).toEqual([
2044 "initial-prompt",
2045 isWorking ? "working-indicator-row" : `turn-fold:${runId}`,
2046 "steer",
2047 ]);
2048 expect(rows[1]?.createdAt).toBe(time(0));
2049 if (!isWorking) expect(rows[1]).toMatchObject({ label: "Worked for 20s", expanded });
2050 expect(rows.some((row) => row.id === "final")).toBe(true);
2051 expect(rows.some((row) => row.id === "work")).toBe(isWorking || expanded);
2052 }
2053 }
2054 },
2055 );
2056
2057 it("keeps the previous turn folded while a newly sent message awaits its turn", () => {
2058 // Right after send, isWorking is true but latestRun still points at the
2059 // previous, settled turn — it must stay folded through that window.
2060 const rows = deriveMessagesTimelineRows({
2061 timelineEntries: [
2062 {
2063 id: "work-entry-1",
2064 kind: "work",
2065 createdAt: "2026-01-01T00:00:05Z",
2066 entry: {
2067 id: "work-1",
2068 createdAt: "2026-01-01T00:00:05Z",
2069 runId: "turn-1" as never,
2070 label: "Ran command",
2071 tone: "tool" as const,
2072 },
2073 },
2074 {
2075 id: "assistant-final-entry",
2076 kind: "message",
2077 createdAt: "2026-01-01T00:00:20Z",
2078 message: {
2079 id: "assistant-final" as never,
2080 role: "assistant",
2081 text: "Done",
2082 runId: "turn-1" as never,
2083 createdAt: "2026-01-01T00:00:20Z",
2084 updatedAt: "2026-01-01T00:00:22Z",
2085 streaming: false,
2086 },
2087 },
2088 {
2089 id: "user-followup-entry",
2090 kind: "message",
2091 createdAt: "2026-01-01T00:01:00Z",
2092 message: {
2093 id: "user-followup" as never,
2094 role: "user",
2095 text: "yooo",
2096 runId: null,
2097 createdAt: "2026-01-01T00:01:00Z",
2098 updatedAt: "2026-01-01T00:01:00Z",
2099 streaming: false,
2100 },
2101 },
2102 ],
2103 latestRun: {
2104 runId: "turn-1" as never,
2105 status: "completed",
2106 startedAt: "2026-01-01T00:00:00Z",
2107 completedAt: "2026-01-01T00:00:22Z",
2108 },
2109 isWorking: true,
2110 activeTurnStartedAt: "2026-01-01T00:01:00Z",
2111 turnDiffSummaries: [],
2112 supportsConversationRollback: false,
2113 });
2114
2115 expect(rows.map((row) => row.id)).toEqual([
2116 "turn-fold:turn-1",
2117 "assistant-final-entry",
2118 "user-followup-entry",
2119 "working-indicator-row",
2120 "live-activity-row",
2121 ]);
2122 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
2123 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
2124 expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
2125 });
2126
2127 it("does not fold the active in-progress turn", () => {
2128 const rows = deriveMessagesTimelineRows({
2129 timelineEntries: [
2130 {
2131 id: "assistant-thought-entry",
2132 kind: "message",
2133 createdAt: "2026-01-01T00:00:05Z",
2134 message: {
2135 id: "assistant-thought" as never,
2136 role: "assistant",
2137 text: "Working on it.",
2138 runId: "turn-1" as never,
2139 createdAt: "2026-01-01T00:00:05Z",
2140 updatedAt: "2026-01-01T00:00:06Z",
2141 streaming: false,
2142 },
2143 },
2144 {
2145 id: "work-entry-1",
2146 kind: "work",
2147 createdAt: "2026-01-01T00:00:08Z",
2148 entry: {
2149 id: "work-1",
2150 createdAt: "2026-01-01T00:00:08Z",
2151 runId: "turn-1" as never,
2152 label: "Ran command",
2153 tone: "tool" as const,
2154 },
2155 },
2156 ],
2157 latestRun: {
2158 runId: "turn-1" as never,
2159 status: "running",
2160 startedAt: "2026-01-01T00:00:00Z",
2161 completedAt: null,
2162 },
2163 isWorking: true,
2164 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2165 turnDiffSummaries: [],
2166 supportsConversationRollback: false,
2167 });
2168
2169 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
2170 expect(rows.map((row) => row.id)).toEqual([
2171 "working-indicator-row",
2172 "assistant-thought-entry",
2173 "live-activity-row",
2174 ]);
2175 });
2176
2177 it("keeps a promptless restart in one active visual response", () => {
2178 const rows = deriveMessagesTimelineRows({
2179 timelineEntries: [
2180 {
2181 id: "user-entry",
2182 kind: "message",
2183 createdAt: "2026-01-01T00:00:00Z",
2184 message: {
2185 id: "user-1" as never,
2186 role: "user",
2187 text: "keep going",
2188 runId: null,
2189 createdAt: "2026-01-01T00:00:00Z",
2190 updatedAt: "2026-01-01T00:00:00Z",
2191 streaming: false,
2192 },
2193 },
2194 {
2195 id: "old-work-entry",
2196 kind: "work",
2197 createdAt: "2026-01-01T00:00:05Z",
2198 entry: {
2199 id: "old-work",
2200 createdAt: "2026-01-01T00:00:05Z",
2201 runId: "turn-before-restart" as never,
2202 label: "Searched files",
2203 command: "rg restart",
2204 tone: "tool" as const,
2205 toolLifecycleStatus: "completed" as const,
2206 },
2207 },
2208 {
2209 id: "old-stale-work-entry",
2210 kind: "work",
2211 createdAt: "2026-01-01T00:00:06Z",
2212 entry: {
2213 id: "old-stale-work",
2214 createdAt: "2026-01-01T00:00:06Z",
2215 runId: "turn-before-restart" as never,
2216 label: "Running stale command",
2217 command: "rg stale",
2218 tone: "tool" as const,
2219 toolLifecycleStatus: "inProgress" as const,
2220 },
2221 },
2222 {
2223 id: "old-commentary-entry",
2224 kind: "message",
2225 createdAt: "2026-01-01T00:00:08Z",
2226 message: {
2227 id: "old-commentary" as never,
2228 role: "assistant",
2229 text: "the server restarted, continuing here.",
2230 runId: "turn-before-restart" as never,
2231 createdAt: "2026-01-01T00:00:08Z",
2232 updatedAt: "2026-01-01T00:00:08Z",
2233 streaming: false,
2234 },
2235 },
2236 {
2237 id: "new-work-entry",
2238 kind: "work",
2239 createdAt: "2026-01-01T00:01:05Z",
2240 entry: {
2241 id: "new-work",
2242 createdAt: "2026-01-01T00:01:05Z",
2243 runId: "turn-after-restart" as never,
2244 label: "Running tests",
2245 command: "vp test run",
2246 tone: "tool" as const,
2247 toolLifecycleStatus: "inProgress" as const,
2248 },
2249 },
2250 ],
2251 latestRun: {
2252 runId: "turn-after-restart" as never,
2253 status: "running",
2254 startedAt: "2026-01-01T00:01:00Z",
2255 completedAt: null,
2256 },
2257 isWorking: true,
2258 activeTurnStartedAt: "2026-01-01T00:01:00Z",
2259 turnDiffSummaries: [],
2260 supportsConversationRollback: false,
2261 });
2262
2263 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
2264 expect(rows.filter((row) => row.id === "working-indicator-row")).toHaveLength(1);
2265 expect(rows.findIndex((row) => row.id === "working-indicator-row")).toBeLessThan(
2266 rows.findIndex((row) => row.id === "old-work-entry"),
2267 );
2268 expect(rows.find((row) => row.id === "working-indicator-row")).toMatchObject({
2269 createdAt: "2026-01-01T00:01:00Z",
2270 });
2271 expect(rows.find((row) => row.id === "old-commentary-entry")).toMatchObject({
2272 showAssistantMeta: false,
2273 showAssistantCopyButton: false,
2274 assistantCopyStreaming: true,
2275 });
2276 expect(rows.filter((row) => row.kind === "work-live" && row.active)).toEqual([
2277 expect.objectContaining({ entry: expect.objectContaining({ id: "new-work" }) }),
2278 ]);
2279 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
2280 });
2281
2282 it("carries the latest thought on the live row while a later tool runs", () => {
2283 type WorkEntry = Extract<
2284 Parameters<typeof deriveMessagesTimelineRows>[0]["timelineEntries"][number],
2285 { kind: "work" }
2286 >;
2287 const work = (
2288 id: string,
2289 at: string,
2290 fields: Omit<WorkEntry["entry"], "id" | "createdAt" | "runId" | "label">,
2291 ): WorkEntry => ({
2292 id: `${id}-entry`,
2293 kind: "work",
2294 createdAt: at,
2295 entry: { id, createdAt: at, runId: "turn-1" as never, label: id, ...fields },
2296 });
2297 const rows = deriveMessagesTimelineRows({
2298 timelineEntries: [
2299 work("old-thought", "2026-01-01T00:00:01Z", {
2300 itemType: "reasoning",
2301 detail: "First idea.",
2302 tone: "thinking" as const,
2303 toolLifecycleStatus: "completed" as const,
2304 }),
2305 work("new-thought", "2026-01-01T00:00:02Z", {
2306 itemType: "reasoning",
2307 detail: "Found the cause.",
2308 tone: "thinking" as const,
2309 toolLifecycleStatus: "completed" as const,
2310 }),
2311 work("running-command", "2026-01-01T00:00:03Z", {
2312 command: "rg cause",
2313 requestKind: "command",
2314 tone: "tool" as const,
2315 toolLifecycleStatus: "inProgress" as const,
2316 }),
2317 ],
2318 latestRun: {
2319 runId: "turn-1" as never,
2320 status: "running",
2321 startedAt: "2026-01-01T00:00:00Z",
2322 completedAt: null,
2323 },
2324 isWorking: true,
2325 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2326 turnDiffSummaries: [],
2327 supportsConversationRollback: false,
2328 });
2329
2330 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
2331 entry: { id: "running-command" },
2332 thought: { id: "new-thought" },
2333 });
2334 });
2335
2336 it("keeps an actually running tool in the shared activity row", () => {
2337 const rows = deriveMessagesTimelineRows({
2338 timelineEntries: [
2339 {
2340 id: "running-command-entry",
2341 kind: "work",
2342 createdAt: "2026-01-01T00:00:05Z",
2343 entry: {
2344 id: "running-command",
2345 createdAt: "2026-01-01T00:00:05Z",
2346 runId: "turn-1" as never,
2347 label: "Running rg",
2348 command: "rg toolCall",
2349 requestKind: "command",
2350 tone: "tool" as const,
2351 toolLifecycleStatus: "inProgress" as const,
2352 },
2353 },
2354 {
2355 id: "completed-edit-entry",
2356 kind: "work",
2357 createdAt: "2026-01-01T00:00:06Z",
2358 entry: {
2359 id: "completed-edit",
2360 createdAt: "2026-01-01T00:00:06Z",
2361 runId: "turn-1" as never,
2362 label: "Edited files",
2363 requestKind: "file-change",
2364 changedFiles: ["src/one.ts", "src/two.ts"],
2365 tone: "tool" as const,
2366 toolLifecycleStatus: "completed" as const,
2367 },
2368 },
2369 {
2370 id: "completed-command-entry",
2371 kind: "work",
2372 createdAt: "2026-01-01T00:00:07Z",
2373 entry: {
2374 id: "completed-command",
2375 createdAt: "2026-01-01T00:00:07Z",
2376 runId: "turn-1" as never,
2377 label: "Ran tests",
2378 command: "vp test run",
2379 requestKind: "command",
2380 tone: "tool" as const,
2381 toolLifecycleStatus: "completed" as const,
2382 },
2383 },
2384 ],
2385 latestRun: {
2386 runId: "turn-1" as never,
2387 status: "running",
2388 startedAt: "2026-01-01T00:00:00Z",
2389 completedAt: null,
2390 },
2391 isWorking: true,
2392 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2393 turnDiffSummaries: [],
2394 supportsConversationRollback: false,
2395 });
2396
2397 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
2398 expect(rows.some((row) => row.kind === "thinking")).toBe(false);
2399 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
2400 entry: { id: "running-command" },
2401 active: true,
2402 groupedEntries: [
2403 { id: "running-command" },
2404 { id: "completed-edit" },
2405 { id: "completed-command" },
2406 ],
2407 });
2408 });
2409
2410 it("folds each run of a provider-native subagent thread like a normal turn", () => {
2411 // A Claude subagent's child thread, as projected: no runs, one runless
2412 // root turn, and a user prompt for the launch and for a SendMessage resume.
2413 const threadId = ThreadId.make("subagent-child");
2414 const rootNodeId = NodeId.make("task-root");
2415 const at = (second: number) =>
2416 DateTime.makeUnsafe(new Date(Date.UTC(2026, 8, 25, 22, 51, second)).toISOString());
2417 const base = (id: string, ordinal: number, second: number, endSecond = second) => ({
2418 id: TurnItemId.make(id),
2419 threadId,
2420 runId: null,
2421 nodeId: rootNodeId,
2422 providerThreadId: null,
2423 providerTurnId: null,
2424 nativeItemRef: null,
2425 parentItemId: null,
2426 ordinal,
2427 status: "completed" as const,
2428 title: null,
2429 startedAt: at(second),
2430 completedAt: at(endSecond),
2431 updatedAt: at(endSecond),
2432 });
2433 const prompt = (id: string, ordinal: number, second: number) => ({
2434 ...base(id, ordinal, second),
2435 type: "user_message" as const,
2436 messageId: MessageId.make(id),
2437 text: `Prompt ${id}`,
2438 attachments: [],
2439 inputIntent: "turn_start" as const,
2440 createdBy: "agent" as const,
2441 creationSource: "provider" as const,
2442 });
2443 const answer = (id: string, ordinal: number, second: number) => ({
2444 ...base(id, ordinal, second),
2445 type: "assistant_message" as const,
2446 messageId: MessageId.make(id),
2447 text: `Answer ${id}`,
2448 streaming: false,
2449 });
2450 type ResumeState = "running" | "completed" | "failed";
2451 const items = (resume: ResumeState) =>
2452 [
2453 prompt("launch", 1, 0),
2454 { ...base("launch-ls", 2, 4), type: "command_execution" as const, input: "ls src" },
2455 {
2456 ...base("launch-thinking", 3, 8),
2457 type: "reasoning" as const,
2458 title: "Thinking",
2459 text: "Not there.",
2460 streaming: false,
2461 },
2462 answer("launch-answer", 4, 8),
2463 prompt("resume", 5, 72),
2464 {
2465 ...base("resume-ls", 6, 77),
2466 type: "command_execution" as const,
2467 input: "ls src",
2468 status: resume === "running" ? ("running" as const) : ("completed" as const),
2469 completedAt: resume === "running" ? null : at(77),
2470 },
2471 ...(resume === "failed"
2472 ? [
2473 {
2474 ...base("resume-error", 7, 80),
2475 type: "error" as const,
2476 status: "failed" as const,
2477 failure: {
2478 class: "provider_error" as const,
2479 message: "Subagent failed",
2480 code: null,
2481 retryable: null,
2482 },
2483 },
2484 ]
2485 : []),
2486 ...(resume === "running" ? [] : [answer("resume-answer", 8, 80)]),
2487 ].map((item, position) => ({
2488 position,
2489 visibility: "local" as const,
2490 sourceThreadId: threadId,
2491 sourceItemId: item.id,
2492 item,
2493 }));
2494 const rows = (input: {
2495 resume: ResumeState;
2496 working: boolean;
2497 expandedRunIds?: ReadonlySet<RunId>;
2498 }) =>
2499 deriveMessagesTimelineRows({
2500 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
2501 visibleTurnItems: items(input.resume),
2502 optimisticMessages: [],
2503 }),
2504 latestRun: null,
2505 isWorking: input.working,
2506 runlessWorkActive: input.working,
2507 ...(input.expandedRunIds === undefined ? {} : { expandedRunIds: input.expandedRunIds }),
2508 activeTurnStartedAt: input.working ? DateTime.formatIso(at(72)) : null,
2509 turnDiffSummaries: [],
2510 supportsConversationRollback: false,
2511 });
2512 const shape = (timeline: ReadonlyArray<MessagesTimelineRow>) =>
2513 timeline.map((row) =>
2514 row.kind === "turn-fold"
2515 ? `fold:${row.label}`
2516 : row.kind === "message"
2517 ? `${row.message.role}:${row.message.id}`
2518 : row.kind,
2519 );
2520
2521 // Settled: each run folds its work, keeping its prompt and final answer.
2522 const settled = rows({ resume: "completed", working: false });
2523 expect(shape(settled)).toEqual([
2524 "user:launch",
2525 "fold:Worked for 8.0s",
2526 "assistant:launch-answer",
2527 "user:resume",
2528 "fold:Worked for 8.0s",
2529 "assistant:resume-answer",
2530 ]);
2531
2532 // Each fold opens on its own.
2533 const launchFold = settled.find((row) => row.kind === "turn-fold");
2534 if (launchFold?.kind !== "turn-fold") throw new Error("Expected the launch fold");
2535 const expanded = rows({
2536 resume: "completed",
2537 working: false,
2538 expandedRunIds: new Set([launchFold.runId]),
2539 });
2540 expect(shape(expanded)).toEqual([
2541 "user:launch",
2542 "fold:Worked for 8.0s",
2543 "work-toggle",
2544 "assistant:launch-answer",
2545 "user:resume",
2546 "fold:Worked for 8.0s",
2547 "assistant:resume-answer",
2548 ]);
2549
2550 // While the resume runs, only the settled launch folds.
2551 expect(shape(rows({ resume: "running", working: true }))).toEqual([
2552 "user:launch",
2553 "fold:Worked for 8.0s",
2554 "assistant:launch-answer",
2555 "user:resume",
2556 "working",
2557 "work-live",
2558 ]);
2559
2560 // A failed run stays open, as on a normal thread.
2561 expect(shape(rows({ resume: "failed", working: false }))).toEqual([
2562 "user:launch",
2563 "fold:Worked for 8.0s",
2564 "assistant:launch-answer",
2565 "user:resume",
2566 "work",
2567 "work",
2568 "assistant:resume-answer",
2569 ]);
2570 });
2571
2572 it("keeps imported V1 turns folded once the thread's first V2 run starts", () => {
2573 const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`;
2574 const message = (
2575 id: string,
2576 role: "user" | "assistant",
2577 second: number,
2578 runId: string | null = null,
2579 ) => ({
2580 id,
2581 kind: "message" as const,
2582 createdAt: at(second),
2583 message: {
2584 id: id as never,
2585 role,
2586 text: id,
2587 runId: runId as never,
2588 createdAt: at(second),
2589 updatedAt: at(second),
2590 streaming: false,
2591 },
2592 });
2593 const rows = (tail: ReadonlyArray<ReturnType<typeof message>>) =>
2594 deriveMessagesTimelineRows({
2595 timelineEntries: [
2596 message("imported-prompt", "user", 0),
2597 message("imported-update", "assistant", 4),
2598 {
2599 id: "imported-command",
2600 kind: "work",
2601 createdAt: at(5),
2602 entry: {
2603 id: "imported-command",
2604 createdAt: at(5),
2605 runId: null,
2606 label: "Ran git",
2607 command: "git status",
2608 requestKind: "command",
2609 tone: "tool" as const,
2610 toolLifecycleStatus: "completed" as const,
2611 },
2612 },
2613 message("imported-answer", "assistant", 8),
2614 ...tail,
2615 ],
2616 latestRun: {
2617 runId: "run-1" as never,
2618 status: "running",
2619 startedAt: at(20),
2620 completedAt: null,
2621 },
2622 isWorking: true,
2623 activeTurnStartedAt: at(20),
2624 turnDiffSummaries: [],
2625 supportsConversationRollback: false,
2626 }).map((row) =>
2627 row.kind === "message" ? `${row.message.role}:${row.message.id}` : row.kind,
2628 );
2629
2630 // V2 work starts from a sent prompt, or with no new prompt (a wake or a resume).
2631 expect(rows([message("new-prompt", "user", 20, "run-1")]).slice(0, 4)).toEqual([
2632 "user:imported-prompt",
2633 "turn-fold",
2634 "assistant:imported-answer",
2635 "user:new-prompt",
2636 ]);
2637 const withoutPrompt = rows([]);
2638 expect(withoutPrompt).toContain("turn-fold");
2639 expect(withoutPrompt).not.toContain("assistant:imported-update");
2640
2641 // Find must open the fold holding a folded imported message by its synthetic key.
2642 const timelineEntries = [
2643 message("imported-prompt", "user", 0),
2644 message("imported-update", "assistant", 4),
2645 message("imported-answer", "assistant", 8),
2646 ];
2647 const foldInput = { timelineEntries, latestRun: null, isWorking: false };
2648 const foldRunId = timelineEntryTurnFoldRunId(foldInput, "imported-update");
2649 expect(foldRunId).not.toBeNull();
2650 // The rendered turn-fold row carries the same key, so find can map it back.
2651 const foldRow = deriveMessagesTimelineRows({
2652 ...foldInput,
2653 turnDiffSummaries: [],
2654 supportsConversationRollback: false,
2655 }).find((row) => row.kind === "turn-fold");
2656 expect(foldRow?.kind === "turn-fold" ? foldRow.runId : null).toBe(foldRunId);
2657 expect(timelineEntryTurnFoldRunId(foldInput, "imported-answer")).toBeNull();
2658 const expanded = deriveMessagesTimelineRows({
2659 ...foldInput,
2660 expandedRunIds: new Set([foldRunId!]),
2661 turnDiffSummaries: [],
2662 supportsConversationRollback: false,
2663 });
2664 expect(
2665 expanded.some((row) => row.kind === "message" && row.message.id === "imported-update"),
2666 ).toBe(true);
2667 });
2668
2669 it("shows a provider-native subagent's runless tools as live work while it works", () => {
2670 const entries = (commandStatus: "inProgress" | "completed") => [
2671 {
2672 id: "task-entry",
2673 kind: "message" as const,
2674 createdAt: "2026-01-01T00:00:00Z",
2675 message: {
2676 id: "task" as never,
2677 role: "user" as const,
2678 text: "Audit the adapters",
2679 runId: null,
2680 createdAt: "2026-01-01T00:00:00Z",
2681 updatedAt: "2026-01-01T00:00:00Z",
2682 streaming: false,
2683 },
2684 },
2685 {
2686 id: "command-entry",
2687 kind: "work" as const,
2688 createdAt: "2026-01-01T00:00:05Z",
2689 entry: {
2690 id: "command",
2691 createdAt: "2026-01-01T00:00:05Z",
2692 runId: null,
2693 label: "Running git",
2694 command: "git diff --stat",
2695 requestKind: "command" as const,
2696 tone: "tool" as const,
2697 toolLifecycleStatus: commandStatus,
2698 },
2699 },
2700 ];
2701 const rows = (input: { commandStatus: "inProgress" | "completed"; working: boolean }) =>
2702 deriveMessagesTimelineRows({
2703 timelineEntries: entries(input.commandStatus),
2704 latestRun: null,
2705 isWorking: input.working,
2706 runlessWorkActive: input.working,
2707 activeTurnStartedAt: input.working ? "2026-01-01T00:00:00Z" : null,
2708 turnDiffSummaries: [],
2709 supportsConversationRollback: false,
2710 });
2711
2712 const running = rows({ commandStatus: "inProgress", working: true });
2713 expect(running.map((row) => row.kind)).toEqual(["message", "working", "work-live"]);
2714 expect(running.find((row) => row.kind === "work-live")).toMatchObject({
2715 entry: { id: "command" },
2716 active: true,
2717 });
2718
2719 // Once the subagent settles, the same entries fold as finished history.
2720 const settled = rows({ commandStatus: "completed", working: false });
2721 expect(settled.map((row) => row.kind)).toEqual(["message", "turn-fold"]);
2722 });
2723
2724 it("does not treat runless entries as live work on a thread with runs", () => {
2725 const rows = deriveMessagesTimelineRows({
2726 timelineEntries: [
2727 {
2728 id: "runless-command-entry",
2729 kind: "work",
2730 createdAt: "2026-01-01T00:00:05Z",
2731 entry: {
2732 id: "runless-command",
2733 createdAt: "2026-01-01T00:00:05Z",
2734 runId: null,
2735 label: "Running git",
2736 command: "git status",
2737 requestKind: "command",
2738 tone: "tool" as const,
2739 toolLifecycleStatus: "inProgress" as const,
2740 },
2741 },
2742 ],
2743 latestRun: {
2744 runId: "turn-1" as never,
2745 status: "running",
2746 startedAt: "2026-01-01T00:00:00Z",
2747 completedAt: null,
2748 },
2749 isWorking: true,
2750 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2751 turnDiffSummaries: [],
2752 supportsConversationRollback: false,
2753 });
2754
2755 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
2756 expect(rows.at(-1)?.kind).toBe("thinking");
2757 });
2758
2759 it("renders a single completed tool call directly", () => {
2760 const rows = deriveMessagesTimelineRows({
2761 timelineEntries: [
2762 {
2763 id: "completed-command-entry",
2764 kind: "work",
2765 createdAt: "2026-01-01T00:00:05Z",
2766 entry: {
2767 id: "completed-command",
2768 createdAt: "2026-01-01T00:00:05Z",
2769 runId: "turn-1" as never,
2770 label: "Ran rg",
2771 command: "rg toolCall",
2772 requestKind: "command",
2773 tone: "tool" as const,
2774 toolLifecycleStatus: "completed" as const,
2775 },
2776 },
2777 {
2778 id: "assistant-commentary-entry",
2779 kind: "message",
2780 createdAt: "2026-01-01T00:00:06Z",
2781 message: {
2782 id: "assistant-commentary" as never,
2783 role: "assistant",
2784 text: "Checking another thing.",
2785 runId: "turn-1" as never,
2786 createdAt: "2026-01-01T00:00:06Z",
2787 updatedAt: "2026-01-01T00:00:06Z",
2788 streaming: false,
2789 },
2790 },
2791 {
2792 id: "running-command-entry",
2793 kind: "work",
2794 createdAt: "2026-01-01T00:00:07Z",
2795 entry: {
2796 id: "running-command",
2797 createdAt: "2026-01-01T00:00:07Z",
2798 runId: "turn-1" as never,
2799 label: "Running tests",
2800 command: "vp test run",
2801 requestKind: "command",
2802 tone: "tool" as const,
2803 toolLifecycleStatus: "inProgress" as const,
2804 },
2805 },
2806 ],
2807 latestRun: {
2808 runId: "turn-1" as never,
2809 status: "running",
2810 startedAt: "2026-01-01T00:00:00Z",
2811 completedAt: null,
2812 },
2813 isWorking: true,
2814 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2815 turnDiffSummaries: [],
2816 supportsConversationRollback: false,
2817 });
2818
2819 expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]);
2820 expect(rows.find((row) => row.kind === "work")).toMatchObject({
2821 groupedEntries: [{ id: "completed-command", command: "rg toolCall" }],
2822 isExpandedToolGroup: false,
2823 displayLabel: "rg toolCall",
2824 });
2825 });
2826
2827 it("keeps separated in-progress tool runs visible", () => {
2828 const rows = deriveMessagesTimelineRows({
2829 timelineEntries: [
2830 {
2831 id: "first-running-entry",
2832 kind: "work",
2833 createdAt: "2026-01-01T00:00:05Z",
2834 entry: {
2835 id: "first-running",
2836 createdAt: "2026-01-01T00:00:05Z",
2837 runId: "turn-1" as never,
2838 label: "Running first command",
2839 command: "rg first",
2840 requestKind: "command",
2841 tone: "tool" as const,
2842 toolLifecycleStatus: "inProgress" as const,
2843 },
2844 },
2845 {
2846 id: "assistant-commentary-entry",
2847 kind: "message",
2848 createdAt: "2026-01-01T00:00:06Z",
2849 message: {
2850 id: "assistant-commentary" as never,
2851 role: "assistant",
2852 text: "Starting another command.",
2853 runId: "turn-1" as never,
2854 createdAt: "2026-01-01T00:00:06Z",
2855 updatedAt: "2026-01-01T00:00:06Z",
2856 streaming: false,
2857 },
2858 },
2859 {
2860 id: "second-running-entry",
2861 kind: "work",
2862 createdAt: "2026-01-01T00:00:07Z",
2863 entry: {
2864 id: "second-running",
2865 createdAt: "2026-01-01T00:00:07Z",
2866 runId: "turn-1" as never,
2867 label: "Running second command",
2868 command: "rg second",
2869 requestKind: "command",
2870 tone: "tool" as const,
2871 toolLifecycleStatus: "inProgress" as const,
2872 },
2873 },
2874 ],
2875 latestRun: {
2876 runId: "turn-1" as never,
2877 status: "running",
2878 startedAt: "2026-01-01T00:00:00Z",
2879 completedAt: null,
2880 },
2881 isWorking: true,
2882 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2883 turnDiffSummaries: [],
2884 supportsConversationRollback: false,
2885 });
2886
2887 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
2888 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
2889 "first-running",
2890 "second-running",
2891 ]);
2892 });
2893
2894 it("does not revive stale in-progress tools before a fresh send has a turn id", () => {
2895 const rows = deriveMessagesTimelineRows({
2896 timelineEntries: [
2897 {
2898 id: "stale-running-entry",
2899 kind: "work",
2900 createdAt: "2026-01-01T00:00:05Z",
2901 entry: {
2902 id: "stale-running",
2903 createdAt: "2026-01-01T00:00:05Z",
2904 runId: "turn-1" as never,
2905 label: "Running stale command",
2906 command: "rg stale",
2907 requestKind: "command",
2908 tone: "tool" as const,
2909 toolLifecycleStatus: "inProgress" as const,
2910 },
2911 },
2912 {
2913 id: "user-followup-entry",
2914 kind: "message",
2915 createdAt: "2026-01-01T00:01:00Z",
2916 message: {
2917 id: "user-followup" as never,
2918 role: "user",
2919 text: "continue",
2920 runId: null,
2921 createdAt: "2026-01-01T00:01:00Z",
2922 updatedAt: "2026-01-01T00:01:00Z",
2923 streaming: false,
2924 },
2925 },
2926 ],
2927 latestRun: null,
2928 isWorking: true,
2929 activeTurnStartedAt: "2026-01-01T00:01:00Z",
2930 turnDiffSummaries: [],
2931 supportsConversationRollback: false,
2932 });
2933
2934 expect(rows.some((row) => row.kind === "work-live")).toBe(false);
2935 });
2936
2937 it("does not revive separated historical task progress", () => {
2938 const rows = deriveMessagesTimelineRows({
2939 timelineEntries: [
2940 {
2941 id: "stale-progress-entry",
2942 kind: "work",
2943 createdAt: "2026-01-01T00:00:05Z",
2944 entry: {
2945 id: "stale-progress",
2946 createdAt: "2026-01-01T00:00:05Z",
2947 runId: "turn-1" as never,
2948 label: "Old progress",
2949 tone: "thinking" as const,
2950 sourceActivityKind: "task.progress" as const,
2951 },
2952 },
2953 {
2954 id: "assistant-commentary-entry",
2955 kind: "message",
2956 createdAt: "2026-01-01T00:00:06Z",
2957 message: {
2958 id: "assistant-commentary" as never,
2959 role: "assistant",
2960 text: "Starting another command.",
2961 runId: "turn-1" as never,
2962 createdAt: "2026-01-01T00:00:06Z",
2963 updatedAt: "2026-01-01T00:00:06Z",
2964 streaming: false,
2965 },
2966 },
2967 {
2968 id: "running-command-entry",
2969 kind: "work",
2970 createdAt: "2026-01-01T00:00:07Z",
2971 entry: {
2972 id: "running-command",
2973 createdAt: "2026-01-01T00:00:07Z",
2974 runId: "turn-1" as never,
2975 label: "Running command",
2976 command: "rg current",
2977 requestKind: "command",
2978 tone: "tool" as const,
2979 toolLifecycleStatus: "inProgress" as const,
2980 },
2981 },
2982 ],
2983 latestRun: {
2984 runId: "turn-1" as never,
2985 status: "running",
2986 startedAt: "2026-01-01T00:00:00Z",
2987 completedAt: null,
2988 },
2989 isWorking: true,
2990 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2991 turnDiffSummaries: [],
2992 supportsConversationRollback: false,
2993 });
2994
2995 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
2996 "running-command",
2997 ]);
2998 });
2999
3000 it.each([
3001 [undefined, true],
3002 ["inProgress", true],
3003 ["completed", false],
3004 ["failed", null],
3005 ["declined", false],
3006 ["stopped", false],
3007 ] as const)(
3008 "respects the %s lifecycle of trailing task progress",
3009 (toolLifecycleStatus, active) => {
3010 const runId = RunId.make("turn-task-progress");
3011 const rows = deriveMessagesTimelineRows({
3012 timelineEntries: [
3013 {
3014 id: "task-progress-entry",
3015 kind: "work",
3016 createdAt: "2026-01-01T00:00:05Z",
3017 entry: {
3018 id: "task-progress",
3019 createdAt: "2026-01-01T00:00:05Z",
3020 runId,
3021 label: "Task progress",
3022 tone: "thinking",
3023 sourceActivityKind: "task.progress",
3024 ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
3025 },
3026 },
3027 ],
3028 latestRun: {
3029 runId,
3030 status: "running",
3031 startedAt: "2026-01-01T00:00:00Z",
3032 completedAt: null,
3033 },
3034 isWorking: true,
3035 activeTurnStartedAt: "2026-01-01T00:00:00Z",
3036 turnDiffSummaries: [],
3037 supportsConversationRollback: false,
3038 });
3039
3040 const workLiveRow = rows.find((row) => row.kind === "work-live");
3041 if (active === null) {
3042 expect(workLiveRow).toBeUndefined();
3043 expect(rows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
3044 } else {
3045 expect(workLiveRow).toMatchObject({ active });
3046 }
3047 },
3048 );
3049
3050 it("reuses one activity row for initial thinking and the latest tool", () => {
3051 const deriveRows = (
3052 toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null,
3053 expandedWorkGroupIds?: ReadonlySet<string>,
3054 ) =>
3055 deriveMessagesTimelineRows({
3056 ...(expandedWorkGroupIds ? { expandedWorkGroupIds } : {}),
3057 timelineEntries:
3058 toolLifecycleStatus === null
3059 ? []
3060 : [
3061 {
3062 id: "latest-command-entry",
3063 kind: "work",
3064 createdAt: "2026-01-01T00:00:05Z",
3065 entry: {
3066 id: "latest-command",
3067 createdAt: "2026-01-01T00:00:05Z",
3068 runId: "turn-1" as never,
3069 label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg",
3070 command: "rg toolCall",
3071 requestKind: "command",
3072 tone: "tool" as const,
3073 toolLifecycleStatus,
3074 ...(toolLifecycleStatus === "inProgress" ? { detail: "exit code 1" } : {}),
3075 },
3076 },
3077 ],
3078 latestRun: {
3079 runId: "turn-1" as never,
3080 status: "running",
3081 startedAt: "2026-01-01T00:00:00Z",
3082 completedAt: null,
3083 },
3084 isWorking: true,
3085 activeTurnStartedAt: "2026-01-01T00:00:00Z",
3086 turnDiffSummaries: [],
3087 supportsConversationRollback: false,
3088 });
3089
3090 const initialRows = deriveRows(null);
3091 const runningRows = deriveRows("inProgress");
3092 const completedRows = deriveRows("completed");
3093 const failedRows = deriveRows("failed");
3094 const declinedRows = deriveRows("declined");
3095 const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row");
3096 const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row");
3097 const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row");
3098
3099 expect(initialActivityRow).toMatchObject({ kind: "thinking" });
3100 expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true });
3101 expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });
3102 expect(failedRows.some((row) => row.kind === "work-live")).toBe(false);
3103 expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
3104 const failedThinkingRow = failedRows.at(-1);
3105 const failedGroupId =
3106 failedThinkingRow?.kind === "thinking" ? failedThinkingRow.groupId : undefined;
3107 expect(failedGroupId).toBeDefined();
3108 const expandedFailedRows = deriveRows("failed", new Set([failedGroupId!]));
3109 expect(expandedFailedRows.slice(-2)).toMatchObject([
3110 { kind: "thinking", id: "live-activity-row", expanded: true },
3111 {
3112 kind: "work",
3113 isExpandedToolGroup: true,
3114 groupedEntries: [{ id: "latest-command" }],
3115 },
3116 ]);
3117 expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
3118 expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
3119 expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
3120 expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
3121 expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
3122 expect(failedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
3123 expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
3124 });
3125
3126 it("does not fold the session's running turn when latestRun regresses", () => {
3127 const rows = deriveMessagesTimelineRows({
3128 timelineEntries: [
3129 {
3130 id: "previous-work-entry",
3131 kind: "work",
3132 createdAt: "2026-01-01T00:00:05Z",
3133 entry: {
3134 id: "previous-work",
3135 createdAt: "2026-01-01T00:00:05Z",
3136 runId: "turn-1" as never,
3137 label: "Read files",
3138 tone: "tool" as const,
3139 },
3140 },
3141 {
3142 id: "user-followup-entry",
3143 kind: "message",
3144 createdAt: "2026-01-01T00:01:00Z",
3145 message: {
3146 id: "user-followup" as never,
3147 role: "user",
3148 text: "continue",
3149 runId: null,
3150 createdAt: "2026-01-01T00:01:00Z",
3151 updatedAt: "2026-01-01T00:01:00Z",
3152 streaming: false,
3153 },
3154 },
3155 {
3156 id: "running-work-entry",
3157 kind: "work",
3158 createdAt: "2026-01-01T00:01:05Z",
3159 entry: {
3160 id: "running-work",
3161 createdAt: "2026-01-01T00:01:05Z",
3162 runId: "turn-2" as never,
3163 label: "Searched files",
3164 tone: "tool" as const,
3165 },
3166 },
3167 ],
3168 latestRun: {
3169 runId: "turn-1" as never,
3170 status: "completed",
3171 startedAt: "2026-01-01T00:00:00Z",
3172 completedAt: "2026-01-01T00:00:25Z",
3173 },
3174 runningRunId: "turn-2" as never,
3175 isWorking: true,
3176 activeTurnStartedAt: "2026-01-01T00:01:00Z",
3177 turnDiffSummaries: [],
3178 supportsConversationRollback: false,
3179 });
3180
3181 expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.runId)).toEqual([
3182 "turn-1",
3183 ]);
3184 expect(rows.map((row) => row.id)).toContain("live-activity-row");
3185 });
3186
3187 it("only shows assistant metadata on the terminal assistant message", () => {
3188 const rows = deriveMessagesTimelineRows({
3189 timelineEntries: [
3190 {
3191 id: "assistant-thought-entry",
3192 kind: "message",
3193 createdAt: "2026-01-01T00:00:10Z",
3194 message: {
3195 id: "assistant-thought" as never,
3196 role: "assistant",
3197 text: "Checking first.",
3198 runId: "turn-1" as never,
3199 createdAt: "2026-01-01T00:00:10Z",
3200 updatedAt: "2026-01-01T00:00:11Z",
3201 streaming: false,
3202 },
3203 },
3204 {
3205 id: "assistant-final-entry",
3206 kind: "message",
3207 createdAt: "2026-01-01T00:00:20Z",
3208 message: {
3209 id: "assistant-final" as never,
3210 role: "assistant",
3211 text: "Done.",
3212 runId: "turn-1" as never,
3213 createdAt: "2026-01-01T00:00:20Z",
3214 updatedAt: "2026-01-01T00:00:30Z",
3215 streaming: false,
3216 },
3217 },
3218 ],
3219 expandedRunIds: new Set(["turn-1" as never]),
3220 isWorking: false,
3221 activeTurnStartedAt: null,
3222 turnDiffSummaries: [],
3223 supportsConversationRollback: false,
3224 });
3225
3226 const assistantRows = rows.filter(
3227 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
3228 row.kind === "message" && row.message.role === "assistant",
3229 );
3230
3231 expect(assistantRows.map((row) => row.showAssistantMeta)).toEqual([false, true]);
3232 });
3233
3234 it("withholds assistant metadata while the active turn is still in progress", () => {
3235 const rows = deriveMessagesTimelineRows({
3236 timelineEntries: [
3237 {
3238 id: "assistant-thought-entry",
3239 kind: "message",
3240 createdAt: "2026-01-01T00:00:10Z",
3241 message: {
3242 id: "assistant-thought" as never,
3243 role: "assistant",
3244 text: "Working on it.",
3245 runId: "turn-1" as never,
3246 createdAt: "2026-01-01T00:00:10Z",
3247 updatedAt: "2026-01-01T00:00:11Z",
3248 streaming: false,
3249 },
3250 },
3251 ],
3252 latestRun: {
3253 runId: "turn-1" as never,
3254 status: "running",
3255 startedAt: "2026-01-01T00:00:00Z",
3256 completedAt: null,
3257 },
3258 isWorking: true,
3259 activeTurnStartedAt: "2026-01-01T00:00:00Z",
3260 turnDiffSummaries: [],
3261 supportsConversationRollback: false,
3262 });
3263
3264 const assistantRow = rows.find(
3265 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
3266 row.kind === "message" && row.message.role === "assistant",
3267 );
3268
3269 expect(assistantRow?.showAssistantMeta).toBe(false);
3270 expect(assistantRow?.showAssistantCopyButton).toBe(false);
3271 expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
3272 });
3273
3274 it.each([
3275 ["tools", "tool", "Used 3 tools"],
3276 ["tools and status updates", "info", "Used 2 tools and received 1 update"],
3277 ] as const)("expands %s through the same activity group", (_, middleTone, summary) => {
3278 const timelineEntries = [
3279 {
3280 id: "work-entry-1",
3281 kind: "work" as const,
3282 createdAt: "2026-01-01T00:00:01Z",
3283 entry: {
3284 id: "work-1",
3285 createdAt: "2026-01-01T00:00:01Z",
3286 label: "read",
3287 detail: "Reading package.json",
3288 tone: "tool" as const,
3289 },
3290 },
3291 {
3292 id: "work-entry-2",
3293 kind: "work" as const,
3294 createdAt: "2026-01-01T00:00:02Z",
3295 entry: {
3296 id: "work-2",
3297 createdAt: "2026-01-01T00:00:02Z",
3298 label: "Status updated",
3299 detail: "Editing MessagesTimeline.tsx",
3300 tone: middleTone,
3301 toolSurface: "computer" as const,
3302 },
3303 },
3304 {
3305 id: "work-entry-3",
3306 kind: "work" as const,
3307 createdAt: "2026-01-01T00:00:03Z",
3308 entry: {
3309 id: "work-3",
3310 createdAt: "2026-01-01T00:00:03Z",
3311 label: "test",
3312 detail: "Running tests",
3313 tone: "tool" as const,
3314 toolSurface: "browser" as const,
3315 toolIcon: { _tag: "website" as const, pageUrl: "https://example.com/checkout" },
3316 },
3317 },
3318 ];
3319
3320 const baseInput = {
3321 timelineEntries,
3322 isWorking: false,
3323 activeTurnStartedAt: null,
3324 turnDiffSummaries: [],
3325 supportsConversationRollback: false,
3326 };
3327 const collapsedRows = deriveMessagesTimelineRows(baseInput);
3328 const expandedRows = deriveMessagesTimelineRows({
3329 ...baseInput,
3330 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
3331 });
3332
3333 expect(collapsedRows.map((row) => row.id)).toEqual(["work-toggle:work-entry-1"]);
3334 expect(collapsedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
3335 groupId: "work-group:work-entry-1",
3336 hiddenCount: 3,
3337 expanded: false,
3338 summary,
3339 toolSurface: "browser",
3340 toolIcon: { _tag: "website", pageUrl: "https://example.com/checkout" },
3341 });
3342 expect(expandedRows.map((row) => row.id)).toEqual([
3343 "work-toggle:work-entry-1",
3344 "work-group:work-entry-1:details",
3345 ]);
3346 expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({
3347 isExpandedToolGroup: true,
3348 groupedEntries: timelineEntries.map(({ entry }) => entry),
3349 });
3350 expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
3351 expanded: true,
3352 });
3353 });
3354
3355 it("deduplicates integration sources and uses the first source icon for the group", () => {
3356 const chromeSource = {
3357 key: "browser-use:chrome",
3358 name: "Chrome",
3359 kind: "integration" as const,
3360 icon: {
3361 _tag: "native-app" as const,
3362 app: { _tag: "display-name" as const, displayName: "Google Chrome" },
3363 },
3364 };
3365 const timelineEntries = [
3366 {
3367 id: "browser-1",
3368 kind: "work" as const,
3369 createdAt: "2026-01-01T00:00:01Z",
3370 entry: {
3371 id: "browser-1",
3372 createdAt: "2026-01-01T00:00:01Z",
3373 label: "Open MATLAB",
3374 tone: "tool" as const,
3375 toolSurface: "browser" as const,
3376 toolSource: chromeSource,
3377 toolIcon: {
3378 _tag: "website" as const,
3379 pageUrl: "https://www.mathworks.com/help/matlab/",
3380 },
3381 },
3382 },
3383 {
3384 id: "browser-2",
3385 kind: "work" as const,
3386 createdAt: "2026-01-01T00:00:02Z",
3387 entry: {
3388 id: "browser-2",
3389 createdAt: "2026-01-01T00:00:02Z",
3390 label: "Show summary",
3391 tone: "tool" as const,
3392 toolSurface: "browser" as const,
3393 toolSource: chromeSource,
3394 toolIcon: {
3395 _tag: "website" as const,
3396 pageUrl: "https://www.mathworks.com/help/matlab/summary.html",
3397 },
3398 },
3399 },
3400 {
3401 id: "command-1",
3402 kind: "work" as const,
3403 createdAt: "2026-01-01T00:00:03Z",
3404 entry: {
3405 id: "command-1",
3406 createdAt: "2026-01-01T00:00:03Z",
3407 label: "Ran command",
3408 command: "git status",
3409 itemType: "command_execution" as const,
3410 tone: "tool" as const,
3411 },
3412 },
3413 ];
3414 const [row] = deriveMessagesTimelineRows({
3415 timelineEntries,
3416 isWorking: false,
3417 activeTurnStartedAt: null,
3418 turnDiffSummaries: [],
3419 supportsConversationRollback: false,
3420 });
3421
3422 expect(row).toMatchObject({
3423 kind: "work-toggle",
3424 summary: "Used Chrome integration and ran 1 command",
3425 toolSurface: "browser",
3426 toolIcon: {
3427 _tag: "website",
3428 pageUrl: "https://www.mathworks.com/help/matlab/",
3429 },
3430 });
3431 });
3432
3433 it.each([true, false])(
3434 "keeps a large expanded tool run inside one timeline item, live=%s",
3435 (isWorking) => {
3436 const runId = RunId.make("turn-many-tools");
3437 const createdAt = "2026-09-01T12:00:00Z";
3438 const timelineEntries = Array.from({ length: 1_000 }, (_, index) => ({
3439 id: `tool-entry-${index}`,
3440 kind: "work" as const,
3441 createdAt,
3442 entry: {
3443 id: `tool-${index}`,
3444 toolCallId: `call-${index}`,
3445 createdAt,
3446 runId,
3447 label: "t3-code.preview_snapshot",
3448 tone: "tool" as const,
3449 toolLifecycleStatus:
3450 isWorking && index === 999 ? ("inProgress" as const) : ("completed" as const),
3451 },
3452 }));
3453 const input = {
3454 timelineEntries,
3455 isWorking,
3456 expandedRunIds: new Set([runId]),
3457 runningRunId: isWorking ? runId : null,
3458 activeTurnStartedAt: isWorking ? createdAt : null,
3459 turnDiffSummaries: [],
3460 supportsConversationRollback: false,
3461 };
3462 const collapsedRows = deriveMessagesTimelineRows(input);
3463 const collapsedGroup = collapsedRows.find(
3464 (row) => row.kind === "work-toggle" || row.kind === "work-live",
3465 );
3466 expect(collapsedGroup).toBeDefined();
3467 const groupId = collapsedGroup!.groupId;
3468 const expandedRows = deriveMessagesTimelineRows({
3469 ...input,
3470 expandedWorkGroupIds: new Set([groupId]),
3471 });
3472 const groupRows = expandedRows.filter((row) => row.kind === "work");
3473 expect(groupRows).toHaveLength(1);
3474 expect(groupRows[0]?.groupedEntries.map(({ id }) => id)).toEqual(
3475 timelineEntries.map(({ entry }) => entry.id),
3476 );
3477 expect(groupRows[0]?.id).toBe(`${groupId}:details`);
3478 expect(deriveMessagesTimelineRows(input).some((row) => row.kind === "work")).toBe(false);
3479 },
3480 );
3481
3482 it.each([
3483 ["recovered", ["failed", "completed"], false],
3484 ["ending in failure", ["completed", "failed"], true],
3485 ["failed", ["failed", "failed"], true],
3486 ] as const)("uses the final call for %s tool groups", (_, statuses, hasFailure) => {
3487 const timelineEntries = statuses.map((status, index) => ({
3488 id: `work-entry-${index}`,
3489 kind: "work" as const,
3490 createdAt: `2026-01-01T00:00:0${index}Z`,
3491 entry: {
3492 id: `work-${index}`,
3493 createdAt: `2026-01-01T00:00:0${index}Z`,
3494 label: "Ran command",
3495 tone: "tool" as const,
3496 itemType: "command_execution" as const,
3497 toolLifecycleStatus: status,
3498 },
3499 }));
3500
3501 const rows = deriveMessagesTimelineRows({
3502 timelineEntries,
3503 isWorking: false,
3504 activeTurnStartedAt: null,
3505 turnDiffSummaries: [],
3506 supportsConversationRollback: false,
3507 });
3508
3509 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
3510 hiddenCount: 2,
3511 hasFailure,
3512 });
3513 });
3514
3515 it.each([
3516 ["the later success is hidden", ["failed", "completed", "info"], false],
3517 ["the later success is visible", ["failed", "info", "completed"], false],
3518 ["an error-toned entry recovers", ["error", "info", "completed"], false],
3519 ["the final failure is hidden", ["completed", "failed", "info"], true],
3520 ["the final failure is visible", ["failed", "info", "failed"], true],
3521 ["the only failure is visible", ["completed", "info", "failed"], true],
3522 ] as const)(
3523 "uses the final tool call for mixed work groups when %s",
3524 (_, statuses, hasFailure) => {
3525 const timelineEntries = statuses.map((status, index) => {
3526 const id = `work-${index}`;
3527 const createdAt = `2026-01-01T00:00:0${index}Z`;
3528
3529 return {
3530 id: `work-entry-${index}`,
3531 kind: "work" as const,
3532 createdAt,
3533 entry:
3534 status === "info"
3535 ? { id, createdAt, label: "Status updated", tone: "info" as const }
3536 : status === "error"
3537 ? { id, createdAt, label: "Command failed", tone: "error" as const }
3538 : {
3539 id,
3540 createdAt,
3541 label: "Ran command",
3542 tone: "tool" as const,
3543 toolLifecycleStatus: status,
3544 },
3545 };
3546 });
3547
3548 const rows = deriveMessagesTimelineRows({
3549 timelineEntries,
3550 isWorking: false,
3551 activeTurnStartedAt: null,
3552 turnDiffSummaries: [],
3553 supportsConversationRollback: false,
3554 });
3555
3556 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
3557 hiddenCount: statuses.some((status) => status === "error") ? 2 : 3,
3558 summary: statuses.some((status) => status === "error")
3559 ? "Received 1 update and used 1 tool"
3560 : "Used 2 tools and received 1 update",
3561 hasFailure,
3562 });
3563 if (statuses.some((status) => status === "error")) {
3564 expect(rows[0]).toMatchObject({
3565 kind: "work",
3566 groupedEntries: [{ tone: "error", label: "Command failed" }],
3567 });
3568 }
3569 },
3570 );
3571});
3572
3573describe("computeStableMessagesTimelineRows", () => {
3574 it("replaces a cached work toggle when its icon presentation changes", () => {
3575 const initialRow: MessagesTimelineRow = {
3576 kind: "work-toggle",
3577 id: "work-toggle:1",
3578 createdAt: "2026-01-01T00:00:00Z",
3579 groupId: "work-group:1",
3580 hiddenCount: 1,
3581 expanded: false,
3582 summary: "Used Browser",
3583 summaryKind: "other",
3584 toolSurface: "browser",
3585 hasFailure: false,
3586 };
3587 const initial = computeStableMessagesTimelineRows([initialRow], {
3588 byId: new Map(),
3589 result: [],
3590 });
3591 const enrichedRow: MessagesTimelineRow = {
3592 ...initialRow,
3593 toolIcon: { _tag: "website", pageUrl: "https://example.com" },
3594 };
3595
3596 const updated = computeStableMessagesTimelineRows([enrichedRow], initial);
3597
3598 expect(updated).not.toBe(initial);
3599 expect(updated.result[0]).toBe(enrichedRow);
3600 });
3601
3602 it.each(["", " \n"])("keeps Thinking after assistant content grows from %j", (text) => {
3603 const startedAt = "2026-01-01T00:00:00Z";
3604 const runId = RunId.make("turn-1");
3605 const input = {
3606 runningRunId: runId,
3607 isWorking: true,
3608 activeTurnStartedAt: startedAt,
3609 turnDiffSummaries: [],
3610 supportsConversationRollback: false,
3611 };
3612 const assistantEntry = {
3613 id: "assistant-entry",
3614 kind: "message" as const,
3615 createdAt: startedAt,
3616 message: {
3617 id: MessageId.make("assistant-1"),
3618 role: "assistant" as const,
3619 text,
3620 runId,
3621 createdAt: startedAt,
3622 updatedAt: startedAt,
3623 streaming: true,
3624 },
3625 };
3626 const initial = computeStableMessagesTimelineRows(
3627 deriveMessagesTimelineRows({ ...input, timelineEntries: [assistantEntry] }),
3628 { byId: new Map(), result: [] },
3629 );
3630 const updated = computeStableMessagesTimelineRows(
3631 deriveMessagesTimelineRows({
3632 ...input,
3633 timelineEntries: [
3634 {
3635 ...assistantEntry,
3636 message: { ...assistantEntry.message, text: "I will inspect the repository." },
3637 },
3638 ],
3639 }),
3640 initial,
3641 );
3642
3643 const initialThinking = initial.byId.get("live-activity-row");
3644 const updatedThinking = updated.byId.get("live-activity-row");
3645 expect(initialThinking).toMatchObject({ kind: "thinking" });
3646 expect(updatedThinking).toBe(initialThinking);
3647 expect(updated.result.at(-1)).toBe(updatedThinking);
3648 });
3649
3650 it("returns the previous result when row order and content are unchanged", () => {
3651 const firstUserMessage = {
3652 id: "user-1" as never,
3653 role: "user" as const,
3654 text: "First",
3655 runId: null,
3656 createdAt: "2026-01-01T00:00:00Z",
3657 updatedAt: "2026-01-01T00:00:00Z",
3658 streaming: false,
3659 };
3660 const secondUserMessage = {
3661 id: "user-2" as never,
3662 role: "user" as const,
3663 text: "Second",
3664 runId: null,
3665 createdAt: "2026-01-01T00:00:10Z",
3666 updatedAt: "2026-01-01T00:00:10Z",
3667 streaming: false,
3668 };
3669
3670 const rows = deriveMessagesTimelineRows({
3671 timelineEntries: [
3672 {
3673 id: "entry-user-1",
3674 kind: "message",
3675 createdAt: firstUserMessage.createdAt,
3676 message: firstUserMessage,
3677 },
3678 {
3679 id: "entry-user-2",
3680 kind: "message",
3681 createdAt: secondUserMessage.createdAt,
3682 message: secondUserMessage,
3683 },
3684 ],
3685 isWorking: false,
3686 activeTurnStartedAt: null,
3687 turnDiffSummaries: [],
3688 supportsConversationRollback: false,
3689 });
3690
3691 const initial = computeStableMessagesTimelineRows(rows, {
3692 byId: new Map(),
3693 result: [],
3694 });
3695
3696 const repeated = computeStableMessagesTimelineRows(rows, initial);
3697
3698 expect(repeated).toBe(initial);
3699 expect(repeated.result).toBe(initial.result);
3700 });
3701
3702 it("reuses work rows when equivalent timeline derivations create new grouped arrays", () => {
3703 const firstWorkEntry = {
3704 id: "work-1",
3705 createdAt: "2026-01-01T00:00:00Z",
3706 label: "thinking",
3707 detail: "Inspecting repository state",
3708 tone: "thinking" as const,
3709 };
3710 const secondWorkEntry = {
3711 id: "work-2",
3712 createdAt: "2026-01-01T00:00:01Z",
3713 label: "read",
3714 detail: "Reading package.json",
3715 tone: "tool" as const,
3716 };
3717
3718 const createRows = () =>
3719 deriveMessagesTimelineRows({
3720 timelineEntries: [
3721 {
3722 id: "entry-work-1",
3723 kind: "work",
3724 createdAt: firstWorkEntry.createdAt,
3725 entry: firstWorkEntry,
3726 },
3727 {
3728 id: "entry-work-2",
3729 kind: "work",
3730 createdAt: secondWorkEntry.createdAt,
3731 entry: secondWorkEntry,
3732 },
3733 ],
3734 isWorking: false,
3735 activeTurnStartedAt: null,
3736 turnDiffSummaries: [],
3737 supportsConversationRollback: false,
3738 });
3739
3740 const firstRows = createRows();
3741 const initial = computeStableMessagesTimelineRows(firstRows, {
3742 byId: new Map(),
3743 result: [],
3744 });
3745 const secondRows = createRows();
3746
3747 expect(secondRows[0]).not.toBe(firstRows[0]);
3748
3749 const repeated = computeStableMessagesTimelineRows(secondRows, initial);
3750
3751 expect(repeated).toBe(initial);
3752 expect(repeated.result[0]).toBe(initial.result[0]);
3753 });
3754
3755 it("returns a new result when row order changes without content changes", () => {
3756 const firstUserMessage = {
3757 id: "user-1" as never,
3758 role: "user" as const,
3759 text: "First",
3760 runId: null,
3761 createdAt: "2026-01-01T00:00:00Z",
3762 updatedAt: "2026-01-01T00:00:00Z",
3763 streaming: false,
3764 };
3765 const secondUserMessage = {
3766 id: "user-2" as never,
3767 role: "user" as const,
3768 text: "Second",
3769 runId: null,
3770 createdAt: "2026-01-01T00:00:10Z",
3771 updatedAt: "2026-01-01T00:00:10Z",
3772 streaming: false,
3773 };
3774
3775 const firstRows = deriveMessagesTimelineRows({
3776 timelineEntries: [
3777 {
3778 id: "entry-user-1",
3779 kind: "message",
3780 createdAt: firstUserMessage.createdAt,
3781 message: firstUserMessage,
3782 },
3783 {
3784 id: "entry-user-2",
3785 kind: "message",
3786 createdAt: secondUserMessage.createdAt,
3787 message: secondUserMessage,
3788 },
3789 ],
3790 isWorking: false,
3791 activeTurnStartedAt: null,
3792 turnDiffSummaries: [],
3793 supportsConversationRollback: false,
3794 });
3795
3796 const initial = computeStableMessagesTimelineRows(firstRows, {
3797 byId: new Map(),
3798 result: [],
3799 });
3800
3801 const reordered = computeStableMessagesTimelineRows([firstRows[1]!, firstRows[0]!], initial);
3802
3803 expect(reordered).not.toBe(initial);
3804 expect(reordered.result).toEqual([initial.result[1], initial.result[0]]);
3805 });
3806});
3807
3808describe("resolveTimelineToolPresentation", () => {
3809 it("pretty prints Claude and Cursor T3 MCP tool names", () => {
3810 expect(resolveTimelineToolPresentation("mcp__t3-code__t3_thread_read")).toEqual({
3811 displayName: "Read a T3 thread",
3812 logo: "t3-code",
3813 });
3814 });
3815
3816 it("pretty prints Codex T3 MCP tool names", () => {
3817 expect(resolveTimelineToolPresentation("t3-code.create_threads")).toEqual({
3818 displayName: "Create T3 threads",
3819 logo: "t3-code",
3820 });
3821 });
3822
3823 it("pretty prints bare T3 MCP toolkit names", () => {
3824 expect(resolveTimelineToolPresentation("list_scheduled_tasks")).toEqual({
3825 displayName: "List scheduled tasks",
3826 logo: "t3-code",
3827 });
3828 });
3829
3830 it("keeps unknown MCP tools on the generic renderer path", () => {
3831 expect(resolveTimelineToolPresentation("mcp__github__search_issues")).toBeNull();
3832 });
3833});
3834
3835describe("v2 run and attempt history", () => {
3836 it("folds settled-turn commentary and work behind a Worked-for row", () => {
3837 const timelineEntries = [
3838 {
3839 id: "user-entry",
3840 kind: "message" as const,
3841 createdAt: "2026-01-01T00:00:00Z",
3842 message: {
3843 id: "user-1" as never,
3844 role: "user" as const,
3845 text: "Build it",
3846 runId: null,
3847 createdAt: "2026-01-01T00:00:00Z",
3848 updatedAt: "2026-01-01T00:00:00Z",
3849 streaming: false,
3850 },
3851 },
3852 {
3853 id: "assistant-thought-entry",
3854 kind: "message" as const,
3855 createdAt: "2026-01-01T00:00:05Z",
3856 message: {
3857 id: "assistant-thought" as never,
3858 role: "assistant" as const,
3859 text: "Looking around first.",
3860 runId: "turn-1" as never,
3861 createdAt: "2026-01-01T00:00:05Z",
3862 updatedAt: "2026-01-01T00:00:06Z",
3863 streaming: false,
3864 },
3865 },
3866 {
3867 id: "work-entry-1",
3868 kind: "work" as const,
3869 createdAt: "2026-01-01T00:00:08Z",
3870 entry: {
3871 id: "work-1",
3872 createdAt: "2026-01-01T00:00:08Z",
3873 runId: "turn-1" as never,
3874 label: "Ran command",
3875 tone: "tool" as const,
3876 },
3877 },
3878 {
3879 id: "provider-recovered-entry",
3880 kind: "work" as const,
3881 createdAt: "2026-01-01T00:00:09Z",
3882 entry: {
3883 id: "provider-recovered",
3884 createdAt: "2026-01-01T00:00:09Z",
3885 runId: "turn-1" as never,
3886 label: "Provider recovered (2/5 retries)",
3887 tone: "info" as const,
3888 itemType: "error" as const,
3889 toolLifecycleStatus: "completed" as const,
3890 },
3891 },
3892 {
3893 id: "thread-created-entry",
3894 kind: "event" as const,
3895 createdAt: "2026-01-01T00:00:10Z",
3896 projectedItem: {
3897 item: {
3898 type: "thread_created",
3899 },
3900 } as never,
3901 },
3902 {
3903 id: "assistant-final-entry",
3904 kind: "message" as const,
3905 createdAt: "2026-01-01T00:00:20Z",
3906 message: {
3907 id: "assistant-final" as never,
3908 role: "assistant" as const,
3909 text: "Done",
3910 runId: "turn-1" as never,
3911 createdAt: "2026-01-01T00:00:20Z",
3912 updatedAt: "2026-01-01T00:00:22Z",
3913 streaming: false,
3914 },
3915 },
3916 ];
3917
3918 const collapsedRows = deriveMessagesTimelineRows({
3919 timelineEntries,
3920 isWorking: false,
3921 turnDiffSummaries: [],
3922 supportsConversationRollback: false,
3923 });
3924
3925 const foldRow = collapsedRows.find(
3926 (row): row is Extract<(typeof collapsedRows)[number], { kind: "turn-fold" }> =>
3927 row.kind === "turn-fold",
3928 );
3929 expect(foldRow?.runId).toBe("turn-1");
3930 expect(foldRow?.expanded).toBe(false);
3931 // User message boundary (00:00:00) → terminal message updatedAt (00:00:22).
3932 expect(foldRow?.label).toBe("Worked for 22s");
3933 expect(collapsedRows.map((row) => row.id)).toEqual([
3934 "user-entry",
3935 "turn-fold:turn-1",
3936 "thread-created-entry",
3937 "assistant-final-entry",
3938 ]);
3939
3940 const expandedRows = deriveMessagesTimelineRows({
3941 timelineEntries,
3942 expandedRunIds: new Set(["turn-1" as never]),
3943 isWorking: false,
3944 turnDiffSummaries: [],
3945 supportsConversationRollback: false,
3946 });
3947
3948 expect(expandedRows.map((row) => row.id)).toEqual([
3949 "user-entry",
3950 "turn-fold:turn-1",
3951 "assistant-thought-entry",
3952 "work-toggle:work-entry-1",
3953 "thread-created-entry",
3954 "assistant-final-entry",
3955 ]);
3956 expect(
3957 expandedRows.find((row) => row.kind === "turn-fold" && row.expanded === true),
3958 ).toBeDefined();
3959 const openedActivity = deriveMessagesTimelineRows({
3960 timelineEntries,
3961 expandedRunIds: new Set(["turn-1" as never]),
3962 expandedWorkGroupIds: new Set(["work-group:work-entry-1"]),
3963 isWorking: false,
3964 turnDiffSummaries: [],
3965 supportsConversationRollback: false,
3966 });
3967 // Expanding the activity preserves tool output and provider recovery details.
3968 const expandedWork = openedActivity.find((row) => row.kind === "work");
3969 expect(expandedWork?.kind === "work" ? expandedWork.groupedEntries : []).toEqual([
3970 expect.objectContaining({ id: "work-1" }),
3971 expect.objectContaining({ id: "provider-recovered" }),
3972 ]);
3973 });
3974 it("hides subagents in folded turns when they arrive before commentary", () => {
3975 const timelineEntries = [
3976 {
3977 id: "user-entry",
3978 kind: "message" as const,
3979 createdAt: "2026-01-01T00:00:00Z",
3980 message: {
3981 id: "user-1" as never,
3982 role: "user" as const,
3983 text: "Spawn a subagent",
3984 runId: null,
3985 createdAt: "2026-01-01T00:00:00Z",
3986 updatedAt: "2026-01-01T00:00:00Z",
3987 streaming: false,
3988 },
3989 },
3990 {
3991 id: "subagent-card-entry",
3992 kind: "event" as const,
3993 createdAt: "2026-01-01T00:00:03Z",
3994 projectedItem: {
3995 item: {
3996 type: "subagent",
3997 runId: "turn-1",
3998 },
3999 } as never,
4000 },
4001 {
4002 id: "assistant-commentary-entry",
4003 kind: "message" as const,
4004 createdAt: "2026-01-01T00:00:05Z",
4005 message: {
4006 id: "assistant-commentary" as never,
4007 role: "assistant" as const,
4008 text: "I spawned the subagent.",
4009 runId: "turn-1" as never,
4010 createdAt: "2026-01-01T00:00:05Z",
4011 updatedAt: "2026-01-01T00:00:06Z",
4012 streaming: false,
4013 },
4014 },
4015 {
4016 id: "assistant-final-entry",
4017 kind: "message" as const,
4018 createdAt: "2026-01-01T00:00:20Z",
4019 message: {
4020 id: "assistant-final" as never,
4021 role: "assistant" as const,
4022 text: "Subagent says: Hello.",
4023 runId: "turn-1" as never,
4024 createdAt: "2026-01-01T00:00:20Z",
4025 updatedAt: "2026-01-01T00:00:22Z",
4026 streaming: false,
4027 },
4028 },
4029 ];
4030
4031 const collapsedRows = deriveMessagesTimelineRows({
4032 timelineEntries,
4033 isWorking: false,
4034 turnDiffSummaries: [],
4035 supportsConversationRollback: false,
4036 });
4037
4038 expect(collapsedRows.map((row) => row.id)).toEqual([
4039 "user-entry",
4040 "turn-fold:turn-1",
4041 "assistant-final-entry",
4042 ]);
4043 });
4044 it("collapses only output from a superseded V2 attempt within the active logical run", () => {
4045 const runId = "run-steered" as never;
4046 const supersededAttemptId = "attempt-1" as never;
4047 const activeAttemptId = "attempt-2" as never;
4048 const supersededAttempt = {
4049 id: supersededAttemptId,
4050 runId,
4051 attemptOrdinal: 1,
4052 rootNodeId: "node-attempt-1" as never,
4053 status: "superseded" as const,
4054 completedAt: null,
4055 };
4056 const activeAttempt = {
4057 id: activeAttemptId,
4058 runId,
4059 attemptOrdinal: 2,
4060 rootNodeId: "node-attempt-2" as never,
4061 status: "running" as const,
4062 completedAt: null,
4063 };
4064 const timelineEntries = [
4065 {
4066 id: "initial-user-entry",
4067 kind: "message" as const,
4068 createdAt: "2026-01-01T00:00:00Z",
4069 attempt: supersededAttempt,
4070 message: {
4071 id: "initial-user" as never,
4072 role: "user" as const,
4073 text: "Build it",
4074 runId,
4075 inputIntent: "turn_start" as const,
4076 createdAt: "2026-01-01T00:00:00Z",
4077 updatedAt: "2026-01-01T00:00:00Z",
4078 streaming: false,
4079 },
4080 },
4081 {
4082 id: "superseded-assistant-entry",
4083 kind: "message" as const,
4084 createdAt: "2026-01-01T00:00:02Z",
4085 attempt: supersededAttempt,
4086 message: {
4087 id: "superseded-assistant" as never,
4088 role: "assistant" as const,
4089 text: "Partial old response",
4090 runId,
4091 createdAt: "2026-01-01T00:00:02Z",
4092 updatedAt: "2026-01-01T00:00:03Z",
4093 streaming: false,
4094 },
4095 },
4096 {
4097 id: "superseded-work-entry",
4098 kind: "work" as const,
4099 createdAt: "2026-01-01T00:00:04Z",
4100 attempt: supersededAttempt,
4101 entry: {
4102 id: "superseded-work",
4103 createdAt: "2026-01-01T00:00:04Z",
4104 runId,
4105 label: "Old command",
4106 tone: "tool" as const,
4107 },
4108 },
4109 {
4110 id: "superseded-thread-created-entry",
4111 kind: "event" as const,
4112 createdAt: "2026-01-01T00:00:04.500Z",
4113 attempt: supersededAttempt,
4114 projectedItem: {
4115 item: {
4116 type: "thread_created",
4117 },
4118 } as never,
4119 },
4120 {
4121 id: "steer-user-entry",
4122 kind: "message" as const,
4123 createdAt: "2026-01-01T00:00:05Z",
4124 attempt: activeAttempt,
4125 message: {
4126 id: "steer-user" as never,
4127 role: "user" as const,
4128 text: "Change direction",
4129 runId,
4130 inputIntent: "steer" as const,
4131 createdAt: "2026-01-01T00:00:05Z",
4132 updatedAt: "2026-01-01T00:00:05Z",
4133 streaming: false,
4134 },
4135 },
4136 {
4137 id: "active-assistant-entry",
4138 kind: "message" as const,
4139 createdAt: "2026-01-01T00:00:06Z",
4140 attempt: activeAttempt,
4141 message: {
4142 id: "active-assistant" as never,
4143 role: "assistant" as const,
4144 text: "Current response",
4145 runId,
4146 createdAt: "2026-01-01T00:00:06Z",
4147 updatedAt: "2026-01-01T00:00:07Z",
4148 streaming: true,
4149 },
4150 },
4151 ];
4152 const common = {
4153 timelineEntries,
4154 latestRun: {
4155 runId,
4156 status: "running" as const,
4157 startedAt: "2026-01-01T00:00:00Z",
4158 completedAt: null,
4159 },
4160 isWorking: false,
4161 turnDiffSummaries: [],
4162 supportsConversationRollback: false,
4163 };
4164
4165 const collapsedRows = deriveMessagesTimelineRows(common);
4166 expect(collapsedRows.map((row) => row.id)).toEqual([
4167 "initial-user-entry",
4168 `attempt-fold:${supersededAttemptId}`,
4169 "superseded-thread-created-entry",
4170 "steer-user-entry",
4171 "active-assistant-entry",
4172 ]);
4173 expect(collapsedRows.find((row) => row.kind === "attempt-fold")).toMatchObject({
4174 attemptId: supersededAttemptId,
4175 runId,
4176 label: "Superseded attempt",
4177 expanded: false,
4178 });
4179
4180 const expandedRows = deriveMessagesTimelineRows({
4181 ...common,
4182 expandedAttemptIds: new Set([supersededAttemptId]),
4183 });
4184 expect(expandedRows.map((row) => row.id)).toEqual([
4185 "initial-user-entry",
4186 `attempt-fold:${supersededAttemptId}`,
4187 "superseded-assistant-entry",
4188 "superseded-work-entry",
4189 "superseded-thread-created-entry",
4190 "steer-user-entry",
4191 "active-assistant-entry",
4192 ]);
4193 });
4194
4195 describe("a steer that supersedes an attempt", () => {
4196 const runId = RunId.make("run-steered");
4197 const at = (second: number) => `2026-01-01T00:00:${String(second).padStart(2, "0")}Z`;
4198 // The steer stamps the superseded attempt at 0:10.
4199 const supersededAttempt = {
4200 id: RunAttemptId.make("attempt-1"),
4201 runId,
4202 attemptOrdinal: 1,
4203 rootNodeId: NodeId.make("node-attempt-1"),
4204 status: "superseded" as const,
4205 completedAt: DateTime.makeUnsafe(at(10)),
4206 };
4207 const nextAttempt = {
4208 id: RunAttemptId.make("attempt-2"),
4209 runId,
4210 attemptOrdinal: 2,
4211 rootNodeId: NodeId.make("node-attempt-2"),
4212 status: "completed" as const,
4213 completedAt: DateTime.makeUnsafe(at(14)),
4214 };
4215 const assistantEntry = (
4216 id: string,
4217 attempt: typeof supersededAttempt | typeof nextAttempt,
4218 completedSecond: number,
4219 ): TimelineEntry => ({
4220 id,
4221 kind: "message",
4222 createdAt: at(2),
4223 attempt,
4224 message: {
4225 id: MessageId.make(id),
4226 role: "assistant",
4227 text: `${id} text`,
4228 runId,
4229 createdAt: at(2),
4230 updatedAt: at(completedSecond),
4231 streaming: false,
4232 },
4233 projectedItem: {
4234 item: {
4235 type: "assistant_message",
4236 status: "completed",
4237 completedAt: DateTime.makeUnsafe(at(completedSecond)),
4238 },
4239 } as never,
4240 });
4241 const rowsFor = (
4242 replyCompletedSecond: number,
4243 runStatus: "running" | "completed" = "completed",
4244 ) =>
4245 deriveMessagesTimelineRows({
4246 timelineEntries: [
4247 {
4248 id: "initial-user-entry",
4249 kind: "message",
4250 createdAt: at(0),
4251 attempt: supersededAttempt,
4252 message: {
4253 id: MessageId.make("initial-user"),
4254 role: "user",
4255 text: "Build it",
4256 runId,
4257 inputIntent: "turn_start",
4258 createdAt: at(0),
4259 updatedAt: at(0),
4260 streaming: false,
4261 },
4262 },
4263 {
4264 id: "old-work-entry",
4265 kind: "work",
4266 createdAt: at(1),
4267 attempt: supersededAttempt,
4268 entry: { id: "old-work", createdAt: at(1), runId, label: "Read", tone: "tool" },
4269 },
4270 assistantEntry("old-reply-entry", supersededAttempt, replyCompletedSecond),
4271 {
4272 id: "steer-user-entry",
4273 kind: "message",
4274 createdAt: at(10),
4275 attempt: nextAttempt,
4276 message: {
4277 id: MessageId.make("steer-user"),
4278 role: "user",
4279 text: "What are you working on?",
4280 runId,
4281 inputIntent: "steer",
4282 createdAt: at(10),
4283 updatedAt: at(10),
4284 streaming: false,
4285 },
4286 },
4287 assistantEntry("new-reply-entry", nextAttempt, 14),
4288 ],
4289 latestRun: {
4290 runId,
4291 status: runStatus,
4292 startedAt: at(0),
4293 completedAt: runStatus === "completed" ? at(14) : null,
4294 },
4295 isWorking: false,
4296 turnDiffSummaries: [],
4297 supportsConversationRollback: false,
4298 });
4299
4300 it.each(["running", "completed"] as const)(
4301 "keeps a reply that finished before the steer visible while the run is %s",
4302 (runStatus) => {
4303 const rows = rowsFor(4, runStatus);
4304 expect(rows.some((row) => row.kind === "attempt-fold")).toBe(false);
4305 expect(rows.map((row) => row.id)).toContain("old-reply-entry");
4306 },
4307 );
4308
4309 it("folds a reply the steer cut off as partial output", () => {
4310 const rows = rowsFor(11, "running");
4311 expect(rows.find((row) => row.kind === "attempt-fold")).toMatchObject({
4312 attemptId: supersededAttempt.id,
4313 label: "Superseded attempt",
4314 });
4315 expect(rows.map((row) => row.id)).not.toContain("old-reply-entry");
4316 });
4317 });
4318
4319 it("hides the interruption request while keeping intervening work and the result", () => {
4320 const runId = "turn-1" as never;
4321 const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({
4322 position: type === "run_interrupt_request" ? 0 : 2,
4323 visibility: "local" as const,
4324 sourceThreadId: "thread-1" as never,
4325 sourceItemId: `item-${type}` as never,
4326 item: {
4327 id: `item-${type}`,
4328 threadId: "thread-1",
4329 runId,
4330 nodeId: null,
4331 providerThreadId: null,
4332 providerTurnId: null,
4333 nativeItemRef: null,
4334 parentItemId: null,
4335 ordinal: type === "run_interrupt_request" ? 0 : 2,
4336 status: "completed",
4337 title: null,
4338 startedAt: null,
4339 completedAt: null,
4340 updatedAt: {},
4341 type,
4342 message: type === "run_interrupt_request" ? "Stopping" : "Stopped",
4343 },
4344 });
4345 const rows = deriveMessagesTimelineRows({
4346 timelineEntries: [
4347 {
4348 id: "interrupt-request",
4349 kind: "event",
4350 createdAt: "2026-01-01T00:00:01Z",
4351 projectedItem: interruptEvent("run_interrupt_request") as never,
4352 },
4353 {
4354 id: "work-entry",
4355 kind: "work",
4356 createdAt: "2026-01-01T00:00:02Z",
4357 entry: {
4358 id: "work-1",
4359 createdAt: "2026-01-01T00:00:02Z",
4360 runId,
4361 label: "Finishing tool output",
4362 tone: "tool",
4363 },
4364 },
4365 {
4366 id: "interrupt-result",
4367 kind: "event",
4368 createdAt: "2026-01-01T00:00:03Z",
4369 projectedItem: interruptEvent("run_interrupt_result") as never,
4370 },
4371 ],
4372 latestRun: {
4373 runId,
4374 status: "interrupted",
4375 startedAt: "2026-01-01T00:00:00Z",
4376 completedAt: "2026-01-01T00:00:03Z",
4377 },
4378 isWorking: false,
4379 turnDiffSummaries: [],
4380 supportsConversationRollback: false,
4381 });
4382
4383 expect(rows.map((row) => row.id)).toEqual(["work-entry", "interrupt-result"]);
4384 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
4385
4386 // The command the interrupt cut short stays visible with its outcome.
4387 const stoppedRows = deriveMessagesTimelineRows({
4388 timelineEntries: [
4389 {
4390 id: "stopped-command-entry",
4391 kind: "work",
4392 createdAt: "2026-01-01T00:00:01Z",
4393 entry: {
4394 id: "stopped-command",
4395 createdAt: "2026-01-01T00:00:01Z",
4396 runId,
4397 label: "Ran command",
4398 tone: "tool",
4399 itemType: "command_execution",
4400 command: "/bin/bash -lc 'sleep 90 && echo slept'",
4401 toolLifecycleStatus: "stopped",
4402 },
4403 },
4404 {
4405 id: "interrupt-result",
4406 kind: "event",
4407 createdAt: "2026-01-01T00:00:03Z",
4408 projectedItem: interruptEvent("run_interrupt_result") as never,
4409 },
4410 ],
4411 latestRun: {
4412 runId,
4413 status: "interrupted",
4414 startedAt: "2026-01-01T00:00:00Z",
4415 completedAt: "2026-01-01T00:00:03Z",
4416 },
4417 isWorking: false,
4418 turnDiffSummaries: [],
4419 supportsConversationRollback: false,
4420 });
4421 expect(stoppedRows.map((row) => row.id)).toEqual(["stopped-command-entry", "interrupt-result"]);
4422 expect(stoppedRows[0]).toMatchObject({
4423 kind: "work",
4424 displayLabel: "sleep 90 && echo slept",
4425 });
4426 });
4427});
4428
4429describe("streaming v2 row projection", () => {
4430 function fixture(text = "") {
4431 const source = makeStreamingTimelineFixture(text);
4432 const timelineInput = { visibleTurnItems: source.visibleTurnItems, optimisticMessages: [] };
4433 const timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput);
4434 const input = {
4435 timelineEntries: timeline.entries,
4436 latestRun: {
4437 runId: source.runId,
4438 status: "running" as const,
4439 startedAt: source.time(5),
4440 completedAt: null,
4441 },
4442 runningRunId: source.runId,
4443 isWorking: true,
4444 activeTurnStartedAt: source.time(5),
4445 turnDiffSummaries: [
4446 {
4447 runId: source.historyRunId,
4448 checkpointTurnCount: 1,
4449 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history"),
4450 status: "ready",
4451 files: [],
4452 assistantMessageId: MessageId.make("history-assistant"),
4453 completedAt: source.time(4),
4454 },
4455 ] satisfies ReadonlyArray<TurnDiffSummary>,
4456 supportsConversationRollback: true,
4457 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
4458 return { ...source, timelineInput, timeline, input };
4459 }
4460
4461 function updateText(items: ReadonlyArray<OrchestrationV2ProjectedTurnItem>, text: string) {
4462 return items.map((row) =>
4463 row.item.type === "assistant_message" && row.item.streaming
4464 ? {
4465 ...row,
4466 item: { ...row.item, text, updatedAt: DateTime.makeUnsafe("2026-09-04T00:00:08.000Z") },
4467 }
4468 : row,
4469 );
4470 }
4471
4472 it.each([
4473 ["", "Now visible"],
4474 [" \n", "Now visible"],
4475 ["Visible", ""],
4476 ["", " \t\n"],
4477 ["Visible", "★ Insight\nKeep these line breaks"],
4478 ])("keeps row parity and history identity from %j to %j", (before, after) => {
4479 const initial = fixture(before);
4480 const previous = deriveMessagesTimelineRowsWithState(initial.input);
4481 Object.freeze(previous.rows);
4482 for (const row of previous.rows) Object.freeze(row);
4483 const timelineInput = {
4484 ...initial.timelineInput,
4485 visibleTurnItems: updateText(initial.visibleTurnItems, after),
4486 };
4487 const timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(
4488 timelineInput,
4489 initial.timeline,
4490 );
4491 // The actual v2 selectors recreate shell/checkpoint summaries on every
4492 // projection event, even when their contents did not change.
4493 const input = {
4494 ...initial.input,
4495 timelineEntries: timeline.entries,
4496 latestRun: { ...initial.input.latestRun },
4497 turnDiffSummaries: initial.input.turnDiffSummaries.map((summary) => ({ ...summary })),
4498 };
4499 const next = deriveMessagesTimelineRowsWithState(input, previous);
4500 expect(next.rows).toEqual(
4501 deriveMessagesTimelineRows({
4502 ...input,
4503 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems(timelineInput),
4504 }),
4505 );
4506 for (const [index, row] of previous.rows.entries()) {
4507 if (
4508 (row.kind === "message" || row.kind === "assistant-meta") &&
4509 row.message.id === "live-assistant"
4510 ) {
4511 expect(next.rows[index]).toMatchObject({ message: { text: after } });
4512 expect(next.rows[index]).toHaveProperty(
4513 "projectedItem",
4514 timelineInput.visibleTurnItems.at(-1),
4515 );
4516 expect(row.message.text).toBe(before);
4517 } else {
4518 expect(next.rows[index]).toBe(row);
4519 }
4520 }
4521 });
4522
4523 it.each(["file", "image", "handoff", "streaming-image"] as const)(
4524 "keeps history references with %s previews through the v2 materializer",
4525 (kind) => {
4526 const initial = fixture("Partial");
4527 const image = {
4528 type: "image" as const,
4529 id: "image",
4530 name: "image.png",
4531 mimeType: "image/png",
4532 sizeBytes: 42,
4533 };
4534 const file = {
4535 type: "file" as const,
4536 id: "file",
4537 name: "file.txt",
4538 mimeType: "text/plain",
4539 sizeBytes: 8,
4540 };
4541 const timelineInput = {
4542 ...initial.timelineInput,
4543 visibleTurnItems: initial.visibleTurnItems.map((row, index) =>
4544 (row.item.type === "user_message" && index === 0) ||
4545 (row.item.type === "assistant_message" &&
4546 row.item.streaming &&
4547 kind === "streaming-image")
4548 ? {
4549 ...row,
4550 item: { ...row.item, attachments: kind === "file" ? [file] : [image, file] },
4551 }
4552 : row,
4553 ),
4554 attachmentUrlById: new Map([
4555 [image.id, kind === "handoff" ? "blob:handoff" : "https://server.test/image"],
4556 ]),
4557 };
4558 const timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput);
4559 const input = { ...initial.input, timelineEntries: timeline.entries };
4560 const previous = deriveMessagesTimelineRowsWithState(input);
4561 const nextTimelineInput = {
4562 ...timelineInput,
4563 visibleTurnItems: updateText(timelineInput.visibleTurnItems, "Next token"),
4564 attachmentUrlById: new Map(timelineInput.attachmentUrlById),
4565 };
4566 const nextTimeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(
4567 nextTimelineInput,
4568 timeline,
4569 );
4570 const nextInput = { ...input, timelineEntries: nextTimeline.entries };
4571 const next = deriveMessagesTimelineRowsWithState(nextInput, previous);
4572 expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput));
4573 for (const [index, row] of previous.rows.entries()) {
4574 if (
4575 (row.kind === "message" || row.kind === "assistant-meta") &&
4576 row.message.id === "live-assistant"
4577 )
4578 continue;
4579 expect(next.rows[index]).toBe(row);
4580 }
4581 const renewedInput = {
4582 ...nextTimelineInput,
4583 attachmentUrlById: new Map([[image.id, "https://renewed.test/image"]]),
4584 };
4585 const renewedTimeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(
4586 renewedInput,
4587 nextTimeline,
4588 );
4589 const renewedRowsInput = { ...input, timelineEntries: renewedTimeline.entries };
4590 const renewed = deriveMessagesTimelineRowsWithState(renewedRowsInput, next);
4591 expect(renewed.rows).toEqual(deriveMessagesTimelineRows(renewedRowsInput));
4592 if (kind !== "file") {
4593 expect(renewed.rows.find((row) => row.id === "history-user")).toMatchObject({
4594 message: { attachments: [{ previewUrl: "https://renewed.test/image" }, file] },
4595 });
4596 }
4597 },
4598 );
4599
4600 it("leaves emitted rows immutable when a projection branches", () => {
4601 const initial = fixture("Initial");
4602 const previous = deriveMessagesTimelineRowsWithState(initial.input);
4603 const branch = (text: string) => {
4604 const timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(
4605 { ...initial.timelineInput, visibleTurnItems: updateText(initial.visibleTurnItems, text) },
4606 initial.timeline,
4607 );
4608 const input = { ...initial.input, timelineEntries: timeline.entries };
4609 const next = deriveMessagesTimelineRowsWithState(input, previous);
4610 expect(next.rows).toEqual(deriveMessagesTimelineRows(input));
4611 return next;
4612 };
4613 const first = branch("First");
4614 branch("Second");
4615 expect(first.rows.find((row) => row.id === "live-assistant")).toMatchObject({
4616 message: { text: "First" },
4617 });
4618 expect(previous.rows.find((row) => row.id === "live-assistant")).toMatchObject({
4619 message: { text: "Initial" },
4620 });
4621 });
4622
4623 it("rebuilds for completion, late failure, metadata, expansion, and older history", () => {
4624 const initial = fixture("Partial");
4625 let timeline = initial.timeline;
4626 let timelineInput = initial.timelineInput;
4627 let input: Parameters<typeof deriveMessagesTimelineRows>[0] = initial.input;
4628 let projection = deriveMessagesTimelineRowsWithState(input);
4629 const check = (changes: Partial<typeof input> = {}) => {
4630 const previous = projection;
4631 timeline = deriveTimelineEntriesFromVisibleTurnItemsWithState(timelineInput, timeline);
4632 input = { ...input, ...changes, timelineEntries: timeline.entries };
4633 projection = deriveMessagesTimelineRowsWithState(input, previous);
4634 expect(projection.rows).toEqual(
4635 deriveMessagesTimelineRows({
4636 ...input,
4637 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems(timelineInput),
4638 }),
4639 );
4640 expect(previous.rows).toEqual(deriveMessagesTimelineRows(previous.input));
4641 };
4642 timelineInput = {
4643 ...timelineInput,
4644 visibleTurnItems: timelineInput.visibleTurnItems.map((row) =>
4645 row.item.type === "assistant_message" && row.item.streaming
4646 ? {
4647 ...row,
4648 item: {
4649 ...row.item,
4650 streaming: false,
4651 status: "completed",
4652 text: "Complete",
4653 updatedAt: DateTime.makeUnsafe(initial.time(9)),
4654 },
4655 }
4656 : row,
4657 ),
4658 };
4659 check({
4660 latestRun: { ...initial.input.latestRun, status: "completed", completedAt: initial.time(9) },
4661 runningRunId: null,
4662 isWorking: false,
4663 });
4664 const lateFailure = initial.visibleTurnItems[1]!;
4665 timelineInput = {
4666 ...timelineInput,
4667 visibleTurnItems: [
4668 ...timelineInput.visibleTurnItems,
4669 {
4670 ...lateFailure,
4671 position: 6,
4672 sourceItemId: TurnItemId.make("late-failure"),
4673 item: {
4674 ...lateFailure.item,
4675 id: TurnItemId.make("late-failure"),
4676 runId: initial.runId,
4677 ordinal: 10,
4678 startedAt: DateTime.makeUnsafe(initial.time(10)),
4679 },
4680 },
4681 ],
4682 };
4683 check();
4684 timelineInput = {
4685 ...timelineInput,
4686 visibleTurnItems: timelineInput.visibleTurnItems.map((row) =>
4687 row.item.type === "assistant_message" && row.item.messageId === "live-assistant"
4688 ? {
4689 ...row,
4690 item: {
4691 ...row.item,
4692 text: "Corrected final text",
4693 updatedAt: DateTime.makeUnsafe(initial.time(11)),
4694 },
4695 }
4696 : row,
4697 ),
4698 };
4699 check();
4700 check({
4701 latestRun: {
4702 ...initial.input.latestRun,
4703 status: "interrupted",
4704 completedAt: initial.time(12),
4705 },
4706 });
4707 check({ expandedRunIds: new Set([initial.historyRunId, initial.runId]) });
4708 const group = projection.rows.find((row) => row.kind === "work-toggle");
4709 check({ expandedWorkGroupIds: new Set(group ? [group.groupId] : []) });
4710 const first = initial.visibleTurnItems[0]!;
4711 if (first.item.type !== "user_message") throw new Error("Expected user fixture");
4712 timelineInput = {
4713 ...timelineInput,
4714 visibleTurnItems: [
4715 {
4716 ...first,
4717 sourceItemId: TurnItemId.make("older-user-item"),
4718 item: {
4719 ...first.item,
4720 id: TurnItemId.make("older-user-item"),
4721 messageId: MessageId.make("older-user"),
4722 startedAt: DateTime.makeUnsafe(initial.time(-60)),
4723 },
4724 },
4725 ...timelineInput.visibleTurnItems,
4726 ].map((row, position) => ({ ...row, position })),
4727 };
4728 check();
4729 check({ supportsConversationRollback: false });
4730 check({
4731 turnDiffSummaries: initial.input.turnDiffSummaries.map((summary) => ({
4732 ...summary,
4733 status: "stale" as const,
4734 })),
4735 });
4736 });
4737});
4738
4739describe("linked timeline resources", () => {
4740 const runId = RunId.make("resource-run");
4741 const event = (id: string, type: "subagent" | "thread_created", eventRunId = runId) => ({
4742 id,
4743 kind: "event" as const,
4744 createdAt: "2026-09-08T10:00:02Z",
4745 projectedItem: { item: { id, type, runId: eventRunId } } as OrchestrationV2ProjectedTurnItem,
4746 });
4747 const common = { isWorking: false, turnDiffSummaries: [], supportsConversationRollback: false };
4748
4749 it("previews a thought and separates subagent cards from worklogs", () => {
4750 const rows = deriveMessagesTimelineRows({
4751 ...common,
4752 timelineEntries: [
4753 {
4754 kind: "work",
4755 id: "thought",
4756 createdAt: "2026-09-08T10:00:01Z",
4757 entry: {
4758 id: "thought",
4759 runId,
4760 createdAt: "2026-09-08T10:00:01Z",
4761 label: "Thought",
4762 itemType: "reasoning",
4763 tone: "thinking",
4764 detail: "First paragraph.\n\nSecond paragraph.",
4765 },
4766 },
4767 event("child", "subagent"),
4768 {
4769 kind: "message",
4770 id: "answer",
4771 createdAt: "2026-09-08T10:00:03Z",
4772 message: {
4773 id: MessageId.make("answer"),
4774 role: "assistant",
4775 text: "Done",
4776 runId,
4777 streaming: false,
4778 createdAt: "2026-09-08T10:00:03Z",
4779 updatedAt: "2026-09-08T10:00:03Z",
4780 },
4781 },
4782 ],
4783 expandedRunIds: new Set([runId]),
4784 });
4785 expect(rows.find((row) => row.kind === "work")).toMatchObject({
4786 displayLabel: "First paragraph. Second paragraph.",
4787 });
4788 expect(rows.find((row) => row.kind === "work")?.continuesWorkLog).toBeUndefined();
4789 expect(rows.find((row) => row.id === "child")?.continuesWorkLog).toBeUndefined();
4790 });
4791
4792 it("groups adjacent subagents without merging across a resource or run boundary", () => {
4793 const rows = deriveMessagesTimelineRows({
4794 ...common,
4795 timelineEntries: [
4796 event("a", "subagent"),
4797 event("b", "subagent"),
4798 event("c", "thread_created"),
4799 event("d", "subagent"),
4800 event("e", "subagent", RunId.make("other-run")),
4801 ],
4802 expandedRunIds: new Set([runId, RunId.make("other-run")]),
4803 });
4804 expect(rows.map((row) => row.id)).toEqual([
4805 "turn-fold:resource-run",
4806 "a",
4807 "c",
4808 "d",
4809 "turn-fold:other-run",
4810 "e",
4811 ]);
4812 expect(rows[1]).toMatchObject({ subagents: [{ item: { id: "a" } }, { item: { id: "b" } }] });
4813 });
4814
4815 it.each([
4816 { status: "completed", envelope: "direct", role: "general" },
4817 { status: "completed", envelope: "structured", role: "general" },
4818 { status: "completed", envelope: "text", role: "general" },
4819 { status: "completed", envelope: "structured", role: "research" },
4820 { status: "running", envelope: "direct", role: "general" },
4821 { status: "running", envelope: "direct", role: "research" },
4822 ] as const)(
4823 "matches $status $role delegation calls by child identity with $envelope output",
4824 ({ status, envelope, role }) => {
4825 const child = (id: string) => {
4826 const entry = event(id, "subagent");
4827 return {
4828 ...entry,
4829 projectedItem: {
4830 item: {
4831 ...entry.projectedItem.item,
4832 origin: "app_owned",
4833 subagentId: id,
4834 prompt:
4835 role === "general" ? id : `Act as the ${role} sub-agent for this task.\n\n${id}`,
4836 childThreadId: null,
4837 },
4838 } as OrchestrationV2ProjectedTurnItem,
4839 };
4840 };
4841 const delegation = (id: string, taskId: string, failed = false): TimelineEntry => ({
4842 id,
4843 kind: "work",
4844 createdAt: "2026-09-08T10:00:02Z",
4845 entry: {
4846 id,
4847 runId,
4848 createdAt: "2026-09-08T10:00:02Z",
4849 label: "Delegated a child task",
4850 tone: failed ? "error" : "tool",
4851 itemType: "dynamic_tool",
4852 toolLifecycleStatus: failed
4853 ? "failed"
4854 : status === "running"
4855 ? "inProgress"
4856 : "completed",
4857 projectedItem: {
4858 item: {
4859 id,
4860 runId,
4861 type: "dynamic_tool",
4862 status: failed ? "failed" : status,
4863 toolName: "t3-code.delegate_task",
4864 input: { task: taskId === "b" ? "a" : taskId, role },
4865 ...(status === "completed"
4866 ? {
4867 output:
4868 envelope === "structured"
4869 ? { content: JSON.stringify({ taskId }), structuredContent: { taskId } }
4870 : envelope === "text"
4871 ? { content: [{ type: "text", text: JSON.stringify({ taskId }) }] }
4872 : { taskId },
4873 }
4874 : {}),
4875 },
4876 } as OrchestrationV2ProjectedTurnItem,
4877 },
4878 });
4879 const rows = deriveMessagesTimelineRows({
4880 ...common,
4881 isWorking: status === "running",
4882 runningRunId: status === "running" ? runId : null,
4883 timelineEntries: [
4884 child("a"),
4885 delegation("delegate-a", "a"),
4886 ...(status === "completed" ? [child("b")] : []),
4887 delegation("delegate-b", "b"),
4888 delegation("unmatched", "other-child"),
4889 child("c"),
4890 delegation("failed", "c", true),
4891 child("d"),
4892 ],
4893 expandedRunIds: new Set([runId]),
4894 });
4895 if (status === "completed") {
4896 expect(rows.find((row) => row.id === "a")).toMatchObject({
4897 subagents: [{ item: { id: "a" } }, { item: { id: "b" } }],
4898 });
4899 expect(rows.some((row) => row.id === "b")).toBe(false);
4900 } else {
4901 expect(rows.find((row) => row.id === "a")).toBeDefined();
4902 expect(rows.find((row) => row.id === "b")).toBeUndefined();
4903 }
4904 expect(rows.find((row) => row.id === "c")).toBeDefined();
4905 expect(rows.find((row) => row.id === "d")).toBeDefined();
4906 const visibleTools = rows.flatMap((row) =>
4907 row.kind === "work" || row.kind === "work-live"
4908 ? row.groupedEntries.map((entry) => entry.id)
4909 : [],
4910 );
4911 expect(visibleTools).toContain("unmatched");
4912 expect(visibleTools).toContain("failed");
4913 expect(visibleTools.includes("delegate-a")).toBe(status === "running");
4914 expect(visibleTools.includes("delegate-b")).toBe(status === "running");
4915 },
4916 );
4917
4918 it("keeps created-chat summaries after the final answer and folds only their timeline rows", () => {
4919 const timelineEntries = [
4920 {
4921 kind: "message" as const,
4922 id: "intro",
4923 createdAt: "2026-09-08T10:00:00Z",
4924 message: {
4925 id: MessageId.make("intro"),
4926 role: "assistant" as const,
4927 text: "Creating the chat",
4928 runId,
4929 streaming: false,
4930 createdAt: "2026-09-08T10:00:00Z",
4931 updatedAt: "2026-09-08T10:00:00Z",
4932 },
4933 },
4934 event("created", "thread_created"),
4935 {
4936 kind: "message" as const,
4937 id: "final",
4938 createdAt: "2026-09-08T10:00:04Z",
4939 message: {
4940 id: MessageId.make("final"),
4941 role: "assistant" as const,
4942 text: "Chat is ready",
4943 runId,
4944 streaming: false,
4945 createdAt: "2026-09-08T10:00:04Z",
4946 updatedAt: "2026-09-08T10:00:04Z",
4947 },
4948 },
4949 ];
4950 const collapsed = deriveMessagesTimelineRows({ ...common, timelineEntries });
4951 expect(collapsed.map((row) => row.id)).toEqual([
4952 "turn-fold:resource-run",
4953 "final",
4954 "summary:created",
4955 "assistant-meta:final",
4956 ]);
4957 expect(collapsed.find((row) => row.id === "summary:created")).toMatchObject({
4958 resourceSummary: true,
4959 });
4960 const expanded = deriveMessagesTimelineRows({
4961 ...common,
4962 timelineEntries,
4963 expandedRunIds: new Set([runId]),
4964 });
4965 expect(expanded.map((row) => row.id)).toEqual([
4966 "turn-fold:resource-run",
4967 "intro",
4968 "created",
4969 "final",
4970 "summary:created",
4971 "assistant-meta:final",
4972 ]);
4973 });
4974});
4975
4976it.each([true, false])(
4977 "keeps a wake notification visible outside folded work, working=%s",
4978 (isWorking) => {
4979 const fixture = makeStreamingTimelineFixture();
4980 const source = fixture.visibleTurnItems.find((row) => row.item.type === "user_message")!;
4981 if (source.item.type !== "user_message") throw new Error("Expected user message fixture");
4982 const notification = {
4983 ...source,
4984 item: {
4985 ...source.item,
4986 type: "notification" as const,
4987 source: { kind: "background_task" as const },
4988 outcome: "completed" as const,
4989 summary: "Delegated task finished",
4990 },
4991 };
4992 const entries = deriveTimelineEntriesFromVisibleTurnItems({
4993 visibleTurnItems: fixture.visibleTurnItems.map((row) =>
4994 row === source ? notification : row,
4995 ),
4996 optimisticMessages: [],
4997 });
4998 const rows = deriveMessagesTimelineRows({
4999 timelineEntries: entries,
5000 isWorking,
5001 runningRunId: fixture.runId,
5002 activeTurnStartedAt: fixture.time(0),
5003 turnDiffSummaries: [],
5004 supportsConversationRollback: false,
5005 });
5006 expect(
5007 rows.some(
5008 (row) =>
5009 row.kind === "work" &&
5010 row.groupedEntries.some((entry) => entry.label === "Delegated task finished"),
5011 ),
5012 ).toBe(true);
5013 },
5014);
5015
5016it.each([true, false])(
5017 "keeps completed responses folded across notification wakes, working=%s",
5018 (isWorking) => {
5019 const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
5020 const work = (id: string, run: string, second: number, notification = false) => ({
5021 kind: "work" as const,
5022 id,
5023 createdAt: time(second),
5024 entry: {
5025 id,
5026 runId: RunId.make(run),
5027 createdAt: time(second),
5028 label: id,
5029 tone: "info" as const,
5030 ...(notification ? { itemType: "notification" as const } : {}),
5031 },
5032 });
5033 const answer = (id: string, run: string, second: number) => ({
5034 kind: "message" as const,
5035 id,
5036 createdAt: time(second),
5037 message: {
5038 id: MessageId.make(id),
5039 role: "assistant" as const,
5040 text: id,
5041 runId: RunId.make(run),
5042 createdAt: time(second),
5043 updatedAt: time(second),
5044 streaming: false,
5045 },
5046 });
5047 const rows = deriveMessagesTimelineRows({
5048 timelineEntries: [
5049 work("launch-tools", "initial", 1),
5050 answer("launched", "initial", 5),
5051 work("notification-a", "wake-a", 20, true),
5052 work("read-a", "wake-a", 21),
5053 answer("ack-a", "wake-a", 25),
5054 work("notification-b", "wake-b", 40, true),
5055 work("read-b", "wake-b", 41),
5056 ...(!isWorking ? [answer("ack-b", "wake-b", 45)] : []),
5057 ],
5058 latestRun: {
5059 runId: RunId.make("wake-b"),
5060 status: isWorking ? "running" : "completed",
5061 startedAt: time(40),
5062 completedAt: isWorking ? null : time(45),
5063 },
5064 isWorking,
5065 activeTurnStartedAt: time(40),
5066 turnDiffSummaries: [],
5067 supportsConversationRollback: false,
5068 });
5069 const ids = rows.map((row) => row.id);
5070 expect(ids).toContain("turn-fold:initial");
5071 expect(ids).not.toContain("launch-tools");
5072 expect(ids).toContain("turn-fold:wake-a");
5073 expect(ids.indexOf("notification-a")).toBeLessThan(ids.indexOf("turn-fold:wake-a"));
5074 expect(ids.indexOf("turn-fold:wake-a")).toBeLessThan(ids.indexOf("ack-a"));
5075 const boundary = isWorking ? "working-indicator-row" : "turn-fold:wake-b";
5076 expect(ids).toContain(boundary);
5077 expect(ids.indexOf("notification-b")).toBeLessThan(ids.indexOf(boundary));
5078 if (isWorking) expect(rows.find((row) => row.id === boundary)?.createdAt).toBe(time(40));
5079 },
5080);
5081
5082it("keeps the working header in place across worktree setup handoff", () => {
5083 const snapshot: WorktreeSetupSnapshot = {
5084 threadId: ThreadId.make("thread-setup"),
5085 phase: "running",
5086 startedAt: "2026-01-01T00:00:00Z",
5087 endedAt: null,
5088 branch: "feature",
5089 baseRef: "main",
5090 worktreePath: null,
5091 setupScript: null,
5092 stages: [],
5093 error: null,
5094 sequence: 3,
5095 };
5096 const userEntry = {
5097 id: "user-entry",
5098 kind: "message",
5099 createdAt: "2026-01-01T00:00:00Z",
5100 message: {
5101 id: "user-1" as never,
5102 role: "user",
5103 text: "Build it",
5104 runId: null,
5105 createdAt: "2026-01-01T00:00:00Z",
5106 updatedAt: "2026-01-01T00:00:00Z",
5107 streaming: false,
5108 },
5109 } as const;
5110 const assistantEntry = {
5111 id: "assistant-entry",
5112 kind: "message",
5113 createdAt: "2026-01-01T00:00:30Z",
5114 message: {
5115 id: "assistant-1" as never,
5116 role: "assistant",
5117 text: "On it",
5118 runId: "turn-1" as never,
5119 createdAt: "2026-01-01T00:00:30Z",
5120 updatedAt: "2026-01-01T00:00:30Z",
5121 streaming: true,
5122 },
5123 } as const;
5124 const withoutMessages = deriveMessagesTimelineRows({
5125 timelineEntries: [],
5126 isWorking: true,
5127 activeTurnStartedAt: "2026-01-01T00:00:00Z",
5128 turnDiffSummaries: [],
5129 supportsConversationRollback: false,
5130 worktreeSetup: snapshot,
5131 });
5132 expect(withoutMessages).toEqual([
5133 { kind: "working", id: "working-indicator-row", createdAt: snapshot.startedAt },
5134 {
5135 kind: "worktree-setup",
5136 id: "worktree-setup-row",
5137 createdAt: "2026-01-01T00:00:00Z",
5138 snapshot,
5139 embedded: false,
5140 },
5141 ]);
5142
5143 // A failed setup never handed off, so the card stays under the send.
5144 const withMessages = deriveMessagesTimelineRows({
5145 timelineEntries: [userEntry, assistantEntry],
5146 isWorking: true,
5147 activeTurnStartedAt: "2026-01-01T00:00:00Z",
5148 turnDiffSummaries: [],
5149 supportsConversationRollback: false,
5150 worktreeSetup: { ...snapshot, phase: "failed" },
5151 });
5152 expect(withMessages.map((row) => row.kind)).toEqual([
5153 "message",
5154 "worktree-setup",
5155 "working",
5156 "message",
5157 "thinking",
5158 ]);
5159
5160 // Once the agent stage is done the setup script may still be running in
5161 // the background: the header owns its progress chip, so no setup row remains.
5162 const stage = (id: "agent" | "setup-script", status: "done" | "running") =>
5163 ({
5164 id,
5165 status,
5166 startedAt: "2026-01-01T00:00:10Z",
5167 endedAt: status === "done" ? "2026-01-01T00:00:11Z" : null,
5168 percent: null,
5169 detail: null,
5170 tail: [],
5171 }) as const;
5172 const asyncSnapshot: WorktreeSetupSnapshot = {
5173 ...snapshot,
5174 stages: [stage("setup-script", "running"), stage("agent", "done")],
5175 };
5176 const liveTurn = {
5177 runId: "turn-1" as never,
5178 status: "running",
5179 startedAt: "2026-01-01T00:00:11Z",
5180 completedAt: null,
5181 } as const;
5182 const asyncRows = deriveMessagesTimelineRows({
5183 timelineEntries: [userEntry],
5184 latestRun: liveTurn,
5185 isWorking: true,
5186 activeTurnStartedAt: "2026-01-01T00:00:00Z",
5187 turnDiffSummaries: [],
5188 supportsConversationRollback: false,
5189 worktreeSetup: asyncSnapshot,
5190 });
5191 expect(asyncRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]);
5192
5193 // Dispatched but not yet visible as a turn: the full card stays put so
5194 // nothing collapses during the handoff.
5195 const handoffRows = deriveMessagesTimelineRows({
5196 timelineEntries: [userEntry],
5197 isWorking: true,
5198 activeTurnStartedAt: "2026-01-01T00:00:00Z",
5199 turnDiffSummaries: [],
5200 supportsConversationRollback: false,
5201 worktreeSetup: asyncSnapshot,
5202 });
5203 expect(handoffRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
5204 expect(handoffRows[2]).toMatchObject({ kind: "worktree-setup", embedded: false });
5205
5206 // A clean finish before the turn is live keeps that same layout, so the card
5207 // does not jump above the header in the gap before the run starts.
5208 const settledRows = deriveMessagesTimelineRows({
5209 timelineEntries: [userEntry],
5210 isWorking: true,
5211 activeTurnStartedAt: "2026-01-01T00:00:00Z",
5212 turnDiffSummaries: [],
5213 supportsConversationRollback: false,
5214 worktreeSetup: {
5215 ...snapshot,
5216 phase: "done",
5217 stages: [stage("setup-script", "done"), stage("agent", "done")],
5218 },
5219 });
5220 expect(settledRows.map((row) => row.kind)).toEqual(["message", "working", "worktree-setup"]);
5221
5222 // A script that already finished has nothing left to show once the turn is live.
5223 const finishedRows = deriveMessagesTimelineRows({
5224 timelineEntries: [userEntry],
5225 latestRun: liveTurn,
5226 isWorking: true,
5227 activeTurnStartedAt: "2026-01-01T00:00:00Z",
5228 turnDiffSummaries: [],
5229 supportsConversationRollback: false,
5230 worktreeSetup: {
5231 ...asyncSnapshot,
5232 stages: [stage("setup-script", "done"), stage("agent", "done")],
5233 },
5234 });
5235 expect(finishedRows.map((row) => row.kind)).toEqual(["message", "working", "thinking"]);
5236});
5237
5238describe("failed turn transcript", () => {
5239 it.each(["provider_error", "usage_limit"] as const)(
5240 "keeps historical %s failures and preceding work visible without disclosures",
5241 (failureClass) => {
5242 const runId = RunId.make("failed-run");
5243 const threadId = ThreadId.make("failed-thread");
5244 const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z");
5245 const base = {
5246 threadId,
5247 runId,
5248 nodeId: null,
5249 providerThreadId: null,
5250 providerTurnId: null,
5251 nativeItemRef: null,
5252 parentItemId: null,
5253 ordinal: 0,
5254 title: null,
5255 startedAt: at,
5256 completedAt: at,
5257 updatedAt: at,
5258 };
5259 const items: OrchestrationV2ProjectedTurnItem[] = [
5260 {
5261 position: 0,
5262 visibility: "local",
5263 sourceThreadId: threadId,
5264 sourceItemId: TurnItemId.make("user"),
5265 item: {
5266 ...base,
5267 id: TurnItemId.make("user"),
5268 type: "user_message",
5269 status: "completed",
5270 messageId: MessageId.make("user"),
5271 createdBy: "user",
5272 creationSource: "web",
5273 inputIntent: "turn_start",
5274 text: "Build it",
5275 attachments: [],
5276 },
5277 },
5278 {
5279 position: 1,
5280 visibility: "local",
5281 sourceThreadId: threadId,
5282 sourceItemId: TurnItemId.make("command"),
5283 item: {
5284 ...base,
5285 id: TurnItemId.make("command"),
5286 type: "command_execution",
5287 status: "completed",
5288 input: "pwd",
5289 output: "",
5290 exitCode: 0,
5291 },
5292 },
5293 {
5294 position: 2,
5295 visibility: "local",
5296 sourceThreadId: threadId,
5297 sourceItemId: TurnItemId.make("failure"),
5298 item: {
5299 ...base,
5300 id: TurnItemId.make("failure"),
5301 type: "error",
5302 status: "failed",
5303 failure: {
5304 class: failureClass,
5305 message: "The provider stopped this turn.\nRetry later.",
5306 code: null,
5307 retryable: true,
5308 },
5309 },
5310 },
5311 ];
5312 const rows = deriveMessagesTimelineRows({
5313 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
5314 visibleTurnItems: items,
5315 optimisticMessages: [],
5316 }).map((entry) => ({
5317 ...entry,
5318 attempt: {
5319 id: RunAttemptId.make("superseded-attempt"),
5320 runId,
5321 attemptOrdinal: 1,
5322 rootNodeId: NodeId.make("superseded-root"),
5323 status: "superseded" as const,
5324 completedAt: null,
5325 },
5326 })),
5327 latestRun: {
5328 runId: RunId.make("newer-run"),
5329 status: "completed",
5330 startedAt: DateTime.formatIso(at),
5331 completedAt: DateTime.formatIso(at),
5332 },
5333 isWorking: false,
5334 turnDiffSummaries: [],
5335 supportsConversationRollback: false,
5336 });
5337 expect(
5338 rows.some(
5339 (row) =>
5340 row.kind === "turn-fold" || row.kind === "attempt-fold" || row.kind === "work-toggle",
5341 ),
5342 ).toBe(false);
5343 const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : []));
5344 expect(work.map((entry) => entry.id)).toEqual(["command", "failure"]);
5345 expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later.");
5346 expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z");
5347 const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({
5348 visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"),
5349 optimisticMessages: [],
5350 });
5351 entriesWithoutTools.splice(1, 0, {
5352 kind: "message",
5353 id: "assistant-before-failure",
5354 createdAt: DateTime.formatIso(at),
5355 message: {
5356 id: MessageId.make("assistant-before-failure"),
5357 role: "assistant",
5358 runId,
5359 text: "Checking the workspace.",
5360 createdAt: DateTime.formatIso(at),
5361 updatedAt: DateTime.formatIso(at),
5362 streaming: false,
5363 },
5364 });
5365 const compactRows = deriveMessagesTimelineRows({
5366 timelineEntries: entriesWithoutTools,
5367 isWorking: false,
5368 turnDiffSummaries: [],
5369 supportsConversationRollback: false,
5370 });
5371 expect(compactRows.map((row) => row.kind)).toEqual([
5372 "message",
5373 "message",
5374 "work",
5375 "assistant-meta",
5376 ]);
5377 expect(compactRows[1]).toMatchObject({ showAssistantMeta: false });
5378 expect(compactRows.at(-1)).toMatchObject({
5379 kind: "assistant-meta",
5380 showAssistantCopyButton: true,
5381 message: { id: "assistant-before-failure" },
5382 });
5383 },
5384 );
5385});
5386
5387describe("live subagents after their parent turn settles", () => {
5388 const runId = RunId.make("parent-run");
5389 const threadId = ThreadId.make("parent-thread");
5390 const providerTurnId = ProviderTurnId.make("parent-provider-turn");
5391 const at = (second: number) =>
5392 DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`);
5393 const base = (id: string, second: number) => ({
5394 id: TurnItemId.make(id),
5395 threadId,
5396 runId,
5397 nodeId: null,
5398 providerThreadId: null,
5399 providerTurnId,
5400 nativeItemRef: null,
5401 parentItemId: null,
5402 ordinal: second,
5403 title: null,
5404 startedAt: at(second),
5405 completedAt: at(second),
5406 updatedAt: at(second),
5407 });
5408 const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
5409 position: item.ordinal,
5410 visibility: "local",
5411 sourceThreadId: threadId,
5412 sourceItemId: item.id,
5413 item,
5414 });
5415 const child = (
5416 id: string,
5417 second: number,
5418 status: OrchestrationV2TurnItem["status"],
5419 origin: "app_owned" | "provider_native" = "app_owned",
5420 ): OrchestrationV2TurnItem => ({
5421 ...base(id, second),
5422 status,
5423 completedAt: status === "running" ? null : at(second),
5424 type: "subagent",
5425 subagentId: NodeId.make(id),
5426 origin,
5427 driver: ProviderDriverKind.make("codex"),
5428 providerInstanceId: ProviderInstanceId.make("codex"),
5429 childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null,
5430 prompt: `Inspect ${id}`,
5431 result: null,
5432 });
5433 const rowsFor = (children: ReadonlyArray<OrchestrationV2TurnItem>) =>
5434 deriveMessagesTimelineRows({
5435 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
5436 visibleTurnItems: (
5437 [
5438 {
5439 ...base("user", 0),
5440 status: "completed",
5441 type: "user_message",
5442 messageId: MessageId.make("user"),
5443 createdBy: "user",
5444 creationSource: "web",
5445 inputIntent: "turn_start",
5446 text: "Delegate the review",
5447 attachments: [],
5448 },
5449 {
5450 ...base("reasoning", 1),
5451 status: "completed",
5452 type: "reasoning",
5453 text: "Two children can split this.",
5454 streaming: false,
5455 },
5456 {
5457 ...base("command", 2),
5458 status: "completed",
5459 type: "command_execution",
5460 input: "pwd",
5461 output: "/repo",
5462 exitCode: 0,
5463 },
5464 ...children,
5465 {
5466 ...base("assistant", 8),
5467 status: "completed",
5468 type: "assistant_message",
5469 messageId: MessageId.make("assistant"),
5470 text: "Both children are running.",
5471 streaming: false,
5472 },
5473 ] satisfies OrchestrationV2TurnItem[]
5474 ).map(projected),
5475 optimisticMessages: [],
5476 }),
5477 latestRun: {
5478 runId,
5479 status: "completed",
5480 startedAt: DateTime.formatIso(at(0)),
5481 completedAt: DateTime.formatIso(at(8)),
5482 },
5483 isWorking: false,
5484 turnDiffSummaries: [],
5485 supportsConversationRollback: false,
5486 });
5487
5488 it.each(["app_owned", "provider_native"] as const)(
5489 "keeps a running %s child visible while the rest of the settled turn folds",
5490 (origin) => {
5491 const rows = rowsFor([child("child", 3, "running", origin)]);
5492 // The reasoning and command still fold; only the live card stays out.
5493 expect(rows.map((row) => row.id)).toEqual([
5494 "user",
5495 `turn-fold:${runId}`,
5496 "child",
5497 "assistant",
5498 ]);
5499 },
5500 );
5501
5502 it.each(["completed", "failed"] as const)("folds a %s child with its settled turn", (status) => {
5503 const rows = rowsFor([child("child", 3, status)]);
5504 expect(rows.some((row) => row.id === "child")).toBe(false);
5505 expect(rows.some((row) => row.kind === "turn-fold")).toBe(true);
5506 });
5507
5508 it("keeps a launch batch visible while any member is still running", () => {
5509 const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]);
5510 expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]);
5511 expect(rows.find((row) => row.id === "done")).toMatchObject({
5512 subagents: [{ item: { id: "done" } }, { item: { id: "live" } }],
5513 });
5514 });
5515
5516 it("keeps a running child visible when a steer supersedes its attempt", () => {
5517 const attempt = {
5518 id: RunAttemptId.make("superseded-attempt"),
5519 runId,
5520 attemptOrdinal: 1,
5521 rootNodeId: NodeId.make("superseded-root"),
5522 status: "superseded" as const,
5523 completedAt: null,
5524 };
5525 const entries = deriveTimelineEntriesFromVisibleTurnItems({
5526 visibleTurnItems: (
5527 [
5528 {
5529 ...base("command", 2),
5530 status: "completed",
5531 type: "command_execution",
5532 input: "pwd",
5533 output: "/repo",
5534 exitCode: 0,
5535 },
5536 child("child", 3, "running"),
5537 ] satisfies OrchestrationV2TurnItem[]
5538 ).map(projected),
5539 optimisticMessages: [],
5540 }).map((entry) => ({ ...entry, attempt }));
5541 const rows = deriveMessagesTimelineRows({
5542 timelineEntries: entries,
5543 latestRun: {
5544 runId,
5545 status: "running",
5546 startedAt: DateTime.formatIso(at(0)),
5547 completedAt: null,
5548 },
5549 isWorking: false,
5550 turnDiffSummaries: [],
5551 supportsConversationRollback: false,
5552 });
5553 expect(rows.map((row) => row.id)).toEqual([`attempt-fold:${attempt.id}`, "child"]);
5554 });
5555});
5556
5557describe("subagent continued by a later turn", () => {
5558 // Stored shape from #15891: resuming a subagent moves its item to the
5559 // resuming run, but the item keeps the ordinal from the turn that started it.
5560 const threadId = ThreadId.make("parent-thread");
5561 const run1 = RunId.make("run-1");
5562 const run2 = RunId.make("run-2");
5563 const at = (second: number) =>
5564 DateTime.makeUnsafe(`2026-10-09T10:00:${String(second).padStart(2, "0")}Z`);
5565 const base = (id: string, runId: RunId, ordinal: number, second: number) => ({
5566 id: TurnItemId.make(id),
5567 threadId,
5568 runId,
5569 nodeId: null,
5570 providerThreadId: null,
5571 providerTurnId: null,
5572 nativeItemRef: null,
5573 parentItemId: null,
5574 ordinal,
5575 status: "completed" as const,
5576 title: null,
5577 startedAt: at(second),
5578 completedAt: at(second),
5579 updatedAt: at(second),
5580 });
5581 const user = (id: string, runId: RunId, ordinal: number, second: number) => ({
5582 ...base(id, runId, ordinal, second),
5583 type: "user_message" as const,
5584 messageId: MessageId.make(id),
5585 createdBy: "user" as const,
5586 creationSource: "web" as const,
5587 inputIntent: "turn_start" as const,
5588 text: `Prompt ${id}`,
5589 attachments: [],
5590 });
5591 const assistant = (id: string, runId: RunId, ordinal: number, second: number) => ({
5592 ...base(id, runId, ordinal, second),
5593 type: "assistant_message" as const,
5594 messageId: MessageId.make(id),
5595 text: `Answer ${id}`,
5596 streaming: false,
5597 });
5598 const reasoning = (id: string, runId: RunId, ordinal: number, second: number) => ({
5599 ...base(id, runId, ordinal, second),
5600 type: "reasoning" as const,
5601 text: "Thinking",
5602 streaming: false,
5603 });
5604 const items: OrchestrationV2TurnItem[] = [
5605 user("prompt-1", run1, 1_000_001, 0),
5606 reasoning("reasoning-1", run1, 1_000_002, 1),
5607 {
5608 ...base("subagent", run2, 1_000_003, 2),
5609 completedAt: at(40),
5610 updatedAt: at(40),
5611 type: "subagent",
5612 subagentId: NodeId.make("subagent"),
5613 origin: "provider_native",
5614 driver: ProviderDriverKind.make("claudeAgent"),
5615 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
5616 childThreadId: null,
5617 prompt: "Research the bug",
5618 result: "Found it",
5619 },
5620 assistant("answer-1", run1, 1_000_004, 3),
5621 user("prompt-2", run2, 2_000_001, 30),
5622 reasoning("reasoning-2", run2, 2_000_002, 31),
5623 assistant("answer-2", run2, 2_000_003, 32),
5624 {
5625 ...base("send-message", run2, 2_000_004, 33),
5626 type: "dynamic_tool",
5627 toolName: "SendMessage",
5628 input: { to: "subagent" },
5629 output: "Sent",
5630 },
5631 assistant("closing-2", run2, 2_000_005, 41),
5632 ];
5633
5634 it("keeps each turn's fold between its own prompt and answer", () => {
5635 const rows = deriveMessagesTimelineRows({
5636 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
5637 visibleTurnItems: items.map((item) => ({
5638 position: item.ordinal,
5639 visibility: "local",
5640 sourceThreadId: threadId,
5641 sourceItemId: item.id,
5642 item,
5643 })),
5644 optimisticMessages: [],
5645 }),
5646 latestRun: {
5647 runId: run2,
5648 status: "completed",
5649 startedAt: DateTime.formatIso(at(30)),
5650 completedAt: DateTime.formatIso(at(41)),
5651 },
5652 isWorking: false,
5653 turnDiffSummaries: [],
5654 supportsConversationRollback: false,
5655 });
5656 expect(rows.map((row) => row.id)).toEqual([
5657 "prompt-1",
5658 `turn-fold:${run1}`,
5659 "answer-1",
5660 "prompt-2",
5661 `turn-fold:${run2}`,
5662 "closing-2",
5663 ]);
5664 });
5665});
5666
5667describe("shouldCollapseUserMessage", () => {
5668 it("measures a quote chip by its label, not its encoded link", () => {
5669 const quote = "A long assistant paragraph that the user quoted. ".repeat(40);
5670 const citation = serializeAssistantCitation({
5671 version: 1,
5672 environmentId: EnvironmentId.make("environment"),
5673 threadId: ThreadId.make("thread"),
5674 messageId: MessageId.make("source"),
5675 text: quote,
5676 comment: "Why does this matter?",
5677 start: 0,
5678 end: quote.length,
5679 prefix: "",
5680 suffix: "",
5681 });
5682
5683 expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false);
5684 expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true);
5685 });
5686
5687 it("measures file links and context chips by their label", () => {
5688 const links = Array.from(
5689 { length: 8 },
5690 (_, index) =>
5691 `[file${index}.ts](/workspace/projects/example/packages/some/deeply/nested/directory/file${index}.ts)`,
5692 );
5693 const text = `Compare ${links.join(", ")} with [terminal 1](t3-context://v1/terminal/${"a".repeat(36)}).`;
5694
5695 expect(text.length).toBeGreaterThan(600);
5696 expect(shouldCollapseUserMessage(text)).toBe(false);
5697 });
5698});