1import {
2 ContextHandoffId,
3 MessageId,
4 CheckpointId,
5 CheckpointScopeId,
6 RuntimeRequestId,
7 NodeId,
8 PlanId,
9 ProviderInstanceId,
10 ProviderDriverKind,
11 ProviderThreadId,
12 ProviderTurnId,
13 RunId,
14 RunAttemptId,
15 ScheduledTaskId,
16 ThreadId,
17 TurnItemId,
18 type OrchestrationV2RunAttempt,
19 type OrchestrationV2ProjectedTurnItem,
20 type OrchestrationV2TurnItem,
21} from "@t3tools/contracts";
22import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
23import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
24import * as DateTime from "effect/DateTime";
25import { describe, expect, it } from "vite-plus/test";
26
27import {
28 workEntryRowLabel,
29 isContextHandoffActivityGroup,
30 buildThreadFeed,
31 deriveThreadFeedPresentation,
32 threadFeedActivityIsVisible,
33 threadFeedRunIsUnsettled,
34 type ThreadFeedActivity,
35 type ThreadFeedEntry,
36 togglePendingUserInputOptionSelection,
37 setPendingUserInputCustomAnswer,
38 isPendingUserInputOptionSelected,
39 buildPendingUserInputAnswers,
40} from "./threadActivity";
41
42const threadId = ThreadId.make("thread-1");
43const sourceThreadId = ThreadId.make("thread-source");
44const runId = RunId.make("run-1");
45
46it("keeps historical plan detail accessible from its paged turn item", () => {
47 const item = {
48 ...base("historical-plan", "2026-08-29T00:00:00.000Z", 1),
49 type: "proposed_plan",
50 planId: "plan-historical",
51 markdown: "Full historical plan text",
52 streaming: false,
53 } as OrchestrationV2TurnItem;
54
55 const entries = buildThreadFeed([projected(item, 0)]);
56 const activity = entries.flatMap((entry) =>
57 entry.type === "activity-group" ? entry.activities : [],
58 )[0];
59 expect(activity?.detail).toBe("Full historical plan text");
60 expect(activity?.getFullDetail()).toContain("Full historical plan text");
61});
62
63it("shows only the structured path in expanded mobile read details", () => {
64 const item: OrchestrationV2TurnItem = {
65 ...base("read-detail", "2026-06-20T00:00:03.000Z", 2),
66 type: "dynamic_tool",
67 toolName: "Read",
68 title: "Read src/env.ts",
69 input: { path: "src/env.ts" },
70 output: "---\nname: env\n---\nsecret content",
71 };
72 const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
73 entry.type === "activity-group" ? entry.activities : [],
74 )[0];
75
76 expect(activity?.getFullDetail()).toBe("src/env.ts");
77 expect(activity?.canExpand).toBe(true);
78 expect(activity?.getCopyText()).not.toContain("secret content");
79 expect(activity?.getFullDetail()).not.toContain("sourceThreadId");
80
81 const withoutPath = buildThreadFeed([
82 projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0),
83 ]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0];
84 expect(withoutPath?.getFullDetail()).toBeNull();
85 expect(withoutPath?.canExpand).toBe(false);
86});
87
88it("keeps system notices prominent for inline Markdown details", () => {
89 const item = {
90 ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2),
91 type: "notification",
92 source: { kind: "system" },
93 outcome: "updated",
94 summary: "T3 Code restarted and resumed this turn",
95 detail: "Continue where you left off.",
96 } as OrchestrationV2TurnItem;
97 const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
98 entry.type === "activity-group" ? entry.activities : [],
99 );
100
101 expect(activity?.summary).toBe("T3 Code restarted and resumed this turn");
102 expect(activity?.prominent).toBe(true);
103 expect(activity?.canExpand).toBe(false);
104 expect(activity?.getFullDetail()).toBe("Continue where you left off.");
105});
106
107it("keeps a runless CI failure and its Mergebot link visible outside completed work", () => {
108 const item: OrchestrationV2TurnItem = {
109 ...base("ci-failure", "2026-06-20T00:00:03.000Z", 2),
110 runId: null,
111 type: "notification",
112 source: { kind: "system" },
113 outcome: "failed",
114 summary: "CI failed for PR #42",
115 detail: "[Open PR #42 in Mergebot](https://example.com/queue/pr/42)",
116 };
117 const feed = buildThreadFeed([projected(command(), 0), projected(item, 1)]);
118 const presented = deriveThreadFeedPresentation(
119 feed,
120 {
121 runId,
122 status: "completed",
123 startedAt: "2026-06-20T00:00:01.000Z",
124 completedAt: "2026-06-20T00:00:02.000Z",
125 },
126 new Set(),
127 );
128 const activity = presented
129 .flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))
130 .find((entry) => entry.projectedItem.item.id === item.id);
131 expect(activity?.prominent).toBe(true);
132 expect(activity?.status).toBe("failure");
133 expect(activity?.getFullDetail()).toBe(item.detail);
134});
135
136it("labels file searches with the adapter title and its search target", () => {
137 const item: OrchestrationV2TurnItem = {
138 ...base("file-search", "2026-06-20T00:00:03.000Z", 2),
139 type: "file_search",
140 title: "Searched TODO in web",
141 pattern: "TODO",
142 };
143 const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
144 entry.type === "activity-group" ? entry.activities : [],
145 )[0];
146
147 expect(activity?.summary).toBe("Searched TODO in web");
148 expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("Searched TODO in web");
149});
150
151it("keeps approval prompts rather than presenting them as tool work", () => {
152 const approval = (
153 id: string,
154 requestKind: "file-read" | "command" | "file-change",
155 ordinal: number,
156 ) =>
157 ({
158 ...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal),
159 type: "approval_request",
160 requestId: RuntimeRequestId.make(`request-${id}`),
161 requestKind,
162 prompt: `Allow ${requestKind}?`,
163 }) satisfies OrchestrationV2TurnItem;
164 const feed = buildThreadFeed([
165 projected(approval("approve-read", "file-read", 1), 0),
166 projected(approval("approve-command", "command", 2), 1),
167 projected(approval("approve-edit", "file-change", 3), 2),
168 ]);
169 const activities = feed.flatMap((entry) =>
170 entry.type === "activity-group" ? entry.activities : [],
171 );
172
173 expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([
174 "Allow file-read?",
175 "Allow command?",
176 "Allow file-change?",
177 ]);
178 expect(activities[0]?.canExpand).toBe(true);
179 expect(
180 summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary,
181 ).not.toMatch(/Ran|changed/);
182});
183
184function base(id: string, updatedAt: string, ordinal: number) {
185 const timestamp = DateTime.makeUnsafe(updatedAt);
186 return {
187 id: TurnItemId.make(id),
188 threadId,
189 runId,
190 nodeId: null,
191 providerThreadId: null,
192 providerTurnId: null,
193 nativeItemRef: null,
194 parentItemId: null,
195 ordinal,
196 status: "completed" as const,
197 title: null,
198 startedAt: timestamp,
199 completedAt: timestamp,
200 updatedAt: timestamp,
201 };
202}
203
204function projected(
205 item: OrchestrationV2TurnItem,
206 position: number,
207 visibility: OrchestrationV2ProjectedTurnItem["visibility"] = "local",
208): OrchestrationV2ProjectedTurnItem {
209 return {
210 position,
211 visibility,
212 sourceThreadId: visibility === "local" ? threadId : sourceThreadId,
213 sourceItemId: item.id,
214 item,
215 };
216}
217
218function userMessage(updatedAt = "2026-06-20T00:00:01.000Z") {
219 return {
220 ...base("item-user", updatedAt, 0),
221 type: "user_message" as const,
222 messageId: MessageId.make("message-user"),
223 createdBy: "user" as const,
224 creationSource: "mobile" as const,
225 inputIntent: "turn_start" as const,
226 text: "Run checks",
227 attachments: [],
228 };
229}
230
231function command(updatedAt = "2026-06-20T00:00:02.000Z") {
232 return {
233 ...base("item-command", updatedAt, 1),
234 type: "command_execution" as const,
235 input: "vp check",
236 output: "ok",
237 exitCode: 0,
238 };
239}
240
241function assistantMessage(updatedAt = "2026-06-20T00:00:03.000Z") {
242 return {
243 ...base("item-assistant", updatedAt, 2),
244 type: "assistant_message" as const,
245 messageId: MessageId.make("message-assistant"),
246 text: "Done",
247 streaming: false,
248 };
249}
250
251describe("buildThreadFeed", () => {
252 it("keeps async answers in question history instead of user bubbles", () => {
253 const requestId = RuntimeRequestId.make("question");
254 const question: OrchestrationV2TurnItem = {
255 ...base("question", "2026-06-20T00:00:01.000Z", 0),
256 type: "user_input_request",
257 requestId,
258 questions: [],
259 questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} },
260 };
261 const reply: OrchestrationV2TurnItem = {
262 ...userMessage(),
263 id: TurnItemId.make("answer"),
264 messageId: MessageId.make(`async-answer:${requestId}`),
265 };
266 const feed = buildThreadFeed([projected(question, 0), projected(reply, 1)]);
267 expect(feed).toHaveLength(1);
268 expect(feed[0]).toMatchObject({
269 type: "activity-group",
270 activities: [{ workEntry: { questionAnswer: question.questionAnswer } }],
271 });
272 expect(buildThreadFeed([projected(reply, 0)])[0]?.type).toBe("message");
273 });
274
275 it("does not create a work group for a message and checkpoint", () => {
276 const checkpoint: OrchestrationV2TurnItem = {
277 ...base("checkpoint", "2026-06-20T00:00:04.000Z", 3),
278 type: "checkpoint",
279 checkpointId: CheckpointId.make("checkpoint"),
280 scopeId: CheckpointScopeId.make("scope"),
281 files: [],
282 };
283 const feed = buildThreadFeed([projected(assistantMessage(), 0), projected(checkpoint, 1)]);
284 expect(feed.map((entry) => entry.type)).toEqual(["message"]);
285 });
286
287 it("omits cached tool output and patch bodies from expanded and copied activity", () => {
288 const rawOutput = "RAW_TOOL_OUTPUT";
289 const items: OrchestrationV2TurnItem[] = [
290 { ...command(), output: rawOutput },
291 {
292 ...base("dynamic-output", "2026-06-20T00:00:03.000Z", 2),
293 type: "dynamic_tool",
294 toolName: "example",
295 input: { query: "keep input" },
296 output: { text: rawOutput },
297 },
298 {
299 ...base("file-output", "2026-06-20T00:00:04.000Z", 3),
300 type: "file_change",
301 fileName: "src/example.ts",
302 diffStr: rawOutput,
303 oldStr: rawOutput,
304 newStr: rawOutput,
305 },
306 ];
307 const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap(
308 (entry) => (entry.type === "activity-group" ? entry.activities : []),
309 );
310 expect(activities).toHaveLength(3);
311 for (const activity of activities) {
312 expect(activity.workEntry.detail).toBeUndefined();
313 expect(activity.getFullDetail()).not.toContain(rawOutput);
314 expect(activity.getCopyText()).not.toContain(rawOutput);
315 }
316 expect(activities[0]?.detail).toBe("vp check");
317 expect(activities[1]?.getFullDetail()).toContain("keep input");
318 expect(activities[2]?.detail).toBe("src/example.ts");
319 expect(items[0]).toMatchObject({ output: rawOutput });
320 });
321
322 it("expands tool rows only when they have detail or withheld output", () => {
323 const items: OrchestrationV2TurnItem[] = [
324 { ...command(), input: "", outputOmitted: true },
325 {
326 ...base("dynamic-empty", "2026-06-20T00:00:03.000Z", 2),
327 type: "dynamic_tool",
328 toolName: "example",
329 input: {},
330 },
331 {
332 ...base("read-omitted", "2026-06-20T00:00:04.000Z", 3),
333 type: "dynamic_tool",
334 toolName: "Read",
335 input: { path: "src/env.ts" },
336 outputOmitted: true,
337 },
338 ];
339 const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap(
340 (entry) => (entry.type === "activity-group" ? entry.activities : []),
341 );
342 expect(
343 activities.map(({ canExpand, fetchesDetail }) => ({ canExpand, fetchesDetail })),
344 ).toEqual([
345 { canExpand: true, fetchesDetail: true },
346 { canExpand: false, fetchesDetail: false },
347 { canExpand: true, fetchesDetail: true },
348 ]);
349 });
350
351 it("recognizes automation attribution after projecting a user message", () => {
352 const feed = buildThreadFeed([
353 projected(
354 {
355 ...userMessage(),
356 createdBy: "agent",
357 creationSource: "server",
358 scheduledTaskId: ScheduledTaskId.make("daily-audit"),
359 },
360 0,
361 ),
362 ]);
363 const messageEntry = feed.find((entry) => entry.type === "message");
364
365 expect(messageEntry).toBeDefined();
366 expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({
367 text: "Run checks",
368 attribution: "automation",
369 });
370 });
371
372 it("keeps the sender of an agent message distinct from its timeline source", () => {
373 const feed = buildThreadFeed([
374 projected(
375 {
376 ...userMessage(),
377 createdBy: "agent",
378 creationSource: "mcp",
379 senderThreadId: sourceThreadId,
380 },
381 0,
382 ),
383 ]);
384 const messageEntry = feed.find((entry) => entry.type === "message");
385 expect(messageEntry?.message.senderThreadId).toBe(sourceThreadId);
386 expect(messageEntry?.message.sourceThreadId).toBe(threadId);
387 });
388
389 it("anchors feedback before later committed turns", () => {
390 const laterUser = {
391 ...userMessage("2026-08-29T00:00:05.000Z"),
392 id: TurnItemId.make("item-later-user"),
393 messageId: MessageId.make("message-later-user"),
394 ordinal: 2,
395 text: "Later user turn",
396 };
397 const laterAssistant = {
398 ...assistantMessage("2026-08-29T00:00:04.000Z"),
399 id: TurnItemId.make("item-later-assistant"),
400 messageId: MessageId.make("message-later-assistant"),
401 ordinal: 3,
402 text: "Later assistant turn",
403 };
404 const localMessage = (id: string, role: "user" | "assistant") => ({
405 id: MessageId.make(id),
406 role,
407 text: id,
408 turnId: null,
409 streaming: false,
410 createdAt: "2026-08-29T00:00:03.000Z",
411 updatedAt: "2026-08-29T00:00:03.000Z",
412 });
413 const feed = buildThreadFeed(
414 [
415 projected(userMessage("2026-08-29T00:00:01.000Z"), 0),
416 projected(laterUser, 1),
417 projected(laterAssistant, 2),
418 ],
419 {
420 anchoredMessages: [
421 localMessage("feedback-user", "user"),
422 localMessage("feedback-assistant", "assistant"),
423 localMessage("message-later-user", "user"),
424 ],
425 },
426 );
427 const messages = feed.filter((entry) => entry.type === "message");
428
429 expect(messages.map((entry) => entry.id)).toEqual([
430 "message-user",
431 "feedback-user",
432 "feedback-assistant",
433 "message-later-user",
434 "message-later-assistant",
435 ]);
436 expect(
437 messages
438 .filter((entry) => entry.id.startsWith("feedback-"))
439 .every((entry) => entry.message.projectedItem === undefined),
440 ).toBe(true);
441 });
442
443 it("keeps prominent activity visible while it is running", () => {
444 expect(
445 threadFeedActivityIsVisible({ prominent: true, status: "neutral", toolLike: true }),
446 ).toBe(true);
447 expect(
448 threadFeedActivityIsVisible({ prominent: false, status: "neutral", toolLike: true }),
449 ).toBe(false);
450 });
451
452 it("keeps provider notices visible outside completed work folds without failure styling", () => {
453 const message = "Safeguards flagged this message. Switched to Opus 4.8.";
454 const item = {
455 ...base("item-system-notice", "2026-06-20T00:00:02.000Z", 1),
456 type: "system_notice" as const,
457 message,
458 };
459 const feed = buildThreadFeed([projected(item, 0)]);
460 const presented = deriveThreadFeedPresentation(feed, null, new Set());
461 const activities = presented.flatMap((entry) =>
462 entry.type === "activity-group" ? entry.activities : [],
463 );
464 expect(activities).toHaveLength(1);
465 expect(activities[0]).toMatchObject({
466 summary: message,
467 detail: message,
468 prominent: true,
469 toolLike: false,
470 status: null,
471 icon: "warning",
472 workEntry: { tone: "info", itemType: "system_notice" },
473 });
474 expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
475 });
476
477 it("presents a usage-limit stop as a warning while preserving its explanation", () => {
478 const message = "Plan usage limit reached. Try again after reset.";
479 const entries = buildThreadFeed([
480 projected(
481 {
482 ...base("item-limit", "2026-06-20T00:00:02.000Z", 1),
483 type: "error",
484 status: "failed",
485 title: "Usage limit reached",
486 failure: { class: "usage_limit", message, code: "usageLimitExceeded", retryable: null },
487 },
488 0,
489 ),
490 ]);
491 const activity = entries.flatMap((entry) =>
492 entry.type === "activity-group" ? entry.activities : [],
493 )[0];
494 expect(activity).toMatchObject({
495 summary: "Usage limit reached",
496 status: "neutral",
497 icon: "warning",
498 });
499 expect(activity?.getFullDetail()).toContain(message);
500 });
501
502 it.each(["transport_error", "usage_limit"] as const)(
503 "presents %s retries and clears warning markers on recovery",
504 (failureClass) => {
505 const retryBase = {
506 ...base("item-provider-retry", "2026-06-20T00:00:02.000Z", 1),
507 type: "error" as const,
508 failure: {
509 class: failureClass,
510 message: "The response stream disconnected.",
511 code: "responseStreamDisconnected",
512 retryable: true,
513 },
514 retry: {
515 attempt: 2,
516 maxAttempts: 5,
517 retryDelayMs: null,
518 },
519 };
520 const runningFeed = buildThreadFeed([
521 projected(
522 {
523 ...retryBase,
524 status: "running",
525 title: "Provider retry",
526 completedAt: null,
527 },
528 0,
529 ),
530 ]);
531 const recoveredFeed = buildThreadFeed([
532 projected(
533 {
534 ...retryBase,
535 status: "completed",
536 title: "Provider recovered",
537 },
538 0,
539 ),
540 ]);
541 if (failureClass === "usage_limit") {
542 const recoveredActivity = recoveredFeed.flatMap((entry) =>
543 entry.type === "activity-group" ? entry.activities : [],
544 )[0];
545 expect(recoveredActivity).toMatchObject({ status: "success", icon: "check" });
546 }
547 const failedFeed = buildThreadFeed([
548 projected(
549 {
550 ...retryBase,
551 status: "failed",
552 title: "Provider retry failed",
553 },
554 0,
555 ),
556 projected(command("2026-06-20T00:00:03.000Z"), 1),
557 ]);
558 const runningActivity = runningFeed.find((entry) => entry.type === "activity-group")
559 ?.activities[0];
560 const recoveredActivity = recoveredFeed.find((entry) => entry.type === "activity-group")
561 ?.activities[0];
562 if (runningActivity === undefined || recoveredActivity === undefined) {
563 throw new Error("Expected provider retry work-log activities.");
564 }
565
566 expect(runningActivity).toMatchObject({
567 summary: "Provider retry",
568 status: "neutral",
569 toolLike: false,
570 });
571 expect(threadFeedActivityIsVisible(runningActivity)).toBe(true);
572 expect(recoveredActivity).toMatchObject({
573 summary: "Provider recovered",
574 status: "success",
575 toolLike: false,
576 });
577 const failedPresentation = deriveThreadFeedPresentation(
578 failedFeed,
579 { runId, status: "running", startedAt: null, completedAt: null },
580 new Set(),
581 );
582 expect(failedPresentation.map((entry) => entry.type)).toEqual([
583 "activity-group",
584 "activity-group",
585 ]);
586 expect(
587 failedPresentation[0]?.type === "activity-group"
588 ? failedPresentation[0].activities[0]?.summary
589 : null,
590 ).toBe("Provider retry failed");
591 },
592 );
593
594 it.each(["pending", "running", "completed"] as const)(
595 "omits %s task progress without hiding adjacent conversation items",
596 (stepStatus) => {
597 const todoItem = {
598 ...base("item-tasks", "2026-06-20T00:00:02.500Z", 2),
599 type: "todo_list" as const,
600 planId: PlanId.make("plan-tasks"),
601 steps: [{ id: "step-1", text: "Verify the change", status: stepStatus }],
602 } satisfies OrchestrationV2TurnItem;
603 const user = projected(userMessage(), 0);
604 const tool = projected(command(), 1);
605 const assistant = projected(assistantMessage(), 3);
606
607 expect(buildThreadFeed([user, tool, projected(todoItem, 2), assistant])).toEqual(
608 buildThreadFeed([user, tool, assistant]),
609 );
610 },
611 );
612
613 it("hides synthetic workspace preparation activity", () => {
614 const workspacePreparation = projected(
615 {
616 ...command(),
617 title: "Workspace ready",
618 input: "Preparing workspace",
619 output: "Workspace preparation completed.",
620 },
621 0,
622 );
623
624 expect(buildThreadFeed([workspacePreparation])).toEqual([]);
625 });
626
627 it("does not treat a queued-only run as live feed activity", () => {
628 expect(
629 threadFeedRunIsUnsettled({
630 runId,
631 status: "queued",
632 startedAt: null,
633 completedAt: null,
634 }),
635 ).toBe(false);
636 expect(
637 threadFeedRunIsUnsettled({
638 runId,
639 status: "running",
640 startedAt: "2026-06-20T00:00:01.000Z",
641 completedAt: null,
642 }),
643 ).toBe(true);
644 expect(
645 threadFeedRunIsUnsettled({
646 runId,
647 status: "completed",
648 startedAt: "2026-06-20T00:00:01.000Z",
649 completedAt: null,
650 }),
651 ).toBe(true);
652 expect(
653 threadFeedRunIsUnsettled({
654 runId,
655 status: "waiting",
656 startedAt: "2026-06-20T00:00:01.000Z",
657 completedAt: null,
658 }),
659 ).toBe(true);
660 });
661
662 it("adds queued input only after dispatch creates its turn item", () => {
663 const dispatchedRunId = RunId.make("run-dispatched-queued");
664 const dispatchedMessageId = MessageId.make("message-dispatched-queued");
665 expect(buildThreadFeed([])).toEqual([]);
666
667 const promotedEntries = buildThreadFeed([
668 projected(
669 {
670 ...userMessage(),
671 id: TurnItemId.make("item-dispatched-queued"),
672 runId: dispatchedRunId,
673 messageId: dispatchedMessageId,
674 inputIntent: "turn_start",
675 },
676 0,
677 ),
678 ]);
679 expect(promotedEntries.map((entry) => entry.id)).toEqual([dispatchedMessageId]);
680 expect(
681 promotedEntries[0]?.type === "message" ? promotedEntries[0].message.inputIntent : undefined,
682 ).toBe("turn_start");
683 });
684
685 it("hides the interruption request and keeps the terminal result", () => {
686 const request = projected(
687 {
688 ...base("item-interrupt-request", "2026-06-20T00:00:02.000Z", 1),
689 type: "run_interrupt_request",
690 message: "Interrupt requested",
691 },
692 0,
693 );
694 const result = projected(
695 {
696 ...base("item-interrupt-result", "2026-06-20T00:00:03.000Z", 2),
697 type: "run_interrupt_result",
698 message: "Run interrupted before provider start",
699 },
700 1,
701 );
702
703 const activities = buildThreadFeed([request, result]).flatMap((entry) =>
704 entry.type === "activity-group" ? entry.activities : [],
705 );
706
707 expect(activities).toHaveLength(1);
708 expect(activities[0]?.summary).toBe("Run interrupted");
709 expect(activities[0]?.detail).toBe("Run interrupted before provider start");
710 expect(
711 deriveThreadFeedPresentation(
712 buildThreadFeed([request, result]),
713 {
714 runId,
715 status: "interrupted",
716 startedAt: "2026-06-20T00:00:01.000Z",
717 completedAt: "2026-06-20T00:00:03.000Z",
718 },
719 new Set(),
720 ).some((entry) => entry.type === "run-fold"),
721 ).toBe(false);
722 });
723
724 it("preserves authoritative V2 order instead of sorting reconstructed collections", () => {
725 const rows = [
726 projected(userMessage("2026-06-20T00:00:03.000Z"), 0),
727 projected(command("2026-06-20T00:00:01.000Z"), 1),
728 projected(assistantMessage("2026-06-20T00:00:02.000Z"), 2),
729 ];
730
731 const feed = buildThreadFeed(rows);
732 expect(feed.map((entry) => entry.type)).toEqual(["message", "activity-group", "message"]);
733 expect(feed.map((entry) => entry.id)).toEqual([
734 "message-user",
735 "local:thread-1:item-command",
736 "message-assistant",
737 ]);
738 const activity = feed.find((entry) => entry.type === "activity-group")?.activities[0];
739 expect(activity?.projectedItem).toBe(rows[1]);
740 expect(activity?.getFullDetail()).toContain('"input": "vp check"');
741 });
742
743 it("keeps adjacent work from different V2 attempts in separate groups", () => {
744 const firstRootNodeId = NodeId.make("node-attempt-1");
745 const secondRootNodeId = NodeId.make("node-attempt-2");
746 const firstCommand = { ...command(), nodeId: firstRootNodeId };
747 const secondCommand = {
748 ...command("2026-06-20T00:00:03.000Z"),
749 id: TurnItemId.make("item-command-retry"),
750 ordinal: 2,
751 nodeId: secondRootNodeId,
752 };
753 const attempts = [
754 {
755 id: RunAttemptId.make("attempt-1"),
756 runId,
757 attemptOrdinal: 1,
758 rootNodeId: firstRootNodeId,
759 providerInstanceId: ProviderInstanceId.make("provider-instance-1"),
760 providerThreadId: ProviderThreadId.make("provider-thread-1"),
761 providerTurnId: null,
762 reason: "initial",
763 status: "completed",
764 startedAt: DateTime.makeUnsafe("2026-06-20T00:00:01.000Z"),
765 completedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"),
766 },
767 {
768 id: RunAttemptId.make("attempt-2"),
769 runId,
770 attemptOrdinal: 2,
771 rootNodeId: secondRootNodeId,
772 providerInstanceId: ProviderInstanceId.make("provider-instance-1"),
773 providerThreadId: ProviderThreadId.make("provider-thread-1"),
774 providerTurnId: null,
775 reason: "retry",
776 status: "completed",
777 startedAt: DateTime.makeUnsafe("2026-06-20T00:00:02.000Z"),
778 completedAt: DateTime.makeUnsafe("2026-06-20T00:00:03.000Z"),
779 },
780 ] satisfies ReadonlyArray<OrchestrationV2RunAttempt>;
781
782 const feed = buildThreadFeed([projected(firstCommand, 0), projected(secondCommand, 1)], {
783 attempts,
784 });
785
786 expect(feed).toHaveLength(2);
787 expect(
788 feed.map((entry) =>
789 entry.type === "activity-group" ? entry.activities[0]?.attemptId : null,
790 ),
791 ).toEqual(["attempt-1", "attempt-2"]);
792 });
793
794 it("retains inherited and synthetic rows with their original projected identity", () => {
795 const inherited = projected(command(), 0, "inherited");
796 const { providerThreadId: _providerThreadId, ...forkBase } = base(
797 "item-fork",
798 "2026-06-20T00:00:03.000Z",
799 2,
800 );
801 const synthetic = projected(
802 {
803 ...forkBase,
804 type: "fork",
805 source: { type: "run", threadId: sourceThreadId, runId },
806 targetThreadId: threadId,
807 },
808 1,
809 "synthetic",
810 );
811
812 const feed = buildThreadFeed([inherited, synthetic]);
813 const activities = feed.flatMap((entry) =>
814 entry.type === "activity-group" ? entry.activities : [],
815 );
816 expect(activities.map((activity) => activity.projectedItem)).toEqual([inherited, synthetic]);
817 expect(activities.map((activity) => activity.projectedItem.visibility)).toEqual([
818 "inherited",
819 "synthetic",
820 ]);
821 expect(activities.at(-1)?.prominent).toBe(true);
822 });
823
824 it("keeps orchestration relationship cards visible when a completed run is folded", () => {
825 const { providerThreadId: _providerThreadId, ...forkBase } = base(
826 "item-fork",
827 "2026-06-20T00:00:02.500Z",
828 2,
829 );
830 const feed = buildThreadFeed([
831 projected(userMessage(), 0),
832 projected(command(), 1),
833 projected(
834 {
835 ...forkBase,
836 type: "fork",
837 source: { type: "run", threadId, runId },
838 targetThreadId: sourceThreadId,
839 },
840 2,
841 ),
842 projected(assistantMessage(), 3),
843 ]);
844
845 const collapsed = deriveThreadFeedPresentation(
846 feed,
847 {
848 runId,
849 status: "completed",
850 startedAt: "2026-06-20T00:00:01.000Z",
851 completedAt: "2026-06-20T00:00:03.000Z",
852 },
853 new Set(),
854 );
855
856 expect(
857 collapsed.some(
858 (entry) =>
859 entry.type === "activity-group" &&
860 entry.activities.some((activity) => activity.projectedItem.item.type === "fork"),
861 ),
862 ).toBe(true);
863 expect(
864 collapsed.some(
865 (entry) =>
866 entry.type === "activity-group" &&
867 entry.activities.some(
868 (activity) => activity.projectedItem.item.type === "command_execution",
869 ),
870 ),
871 ).toBe(false);
872 });
873
874 it("keeps opening and final assistant messages around the first hidden work", () => {
875 const opening = {
876 ...assistantMessage("2026-06-20T00:00:01.500Z"),
877 id: TurnItemId.make("item-opening"),
878 messageId: MessageId.make("message-opening"),
879 text: "I will check the deployment configuration.",
880 };
881 const middle = {
882 ...assistantMessage("2026-06-20T00:00:02.500Z"),
883 id: TurnItemId.make("item-middle"),
884 messageId: MessageId.make("message-middle"),
885 text: "The configuration is valid; checking the build next.",
886 };
887 const feed = buildThreadFeed([
888 projected(userMessage(), 0),
889 projected(opening, 1),
890 projected(command(), 2),
891 projected(middle, 3),
892 projected(assistantMessage(), 4),
893 ]);
894 const latestRun = {
895 runId,
896 status: "completed" as const,
897 startedAt: "2026-06-20T00:00:01.000Z",
898 completedAt: "2026-06-20T00:00:03.000Z",
899 };
900
901 const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
902 expect(collapsed.map((entry) => entry.id)).toEqual([
903 "message-user",
904 "message-opening",
905 "run-fold:run-1",
906 "message-assistant",
907 ]);
908 expect(collapsed[1]).toMatchObject({ message: { text: opening.text } });
909 expect(collapsed[2]).toMatchObject({
910 type: "run-fold",
911 createdAt: "2026-06-20T00:00:02.000Z",
912 label: "Worked for 2.0s",
913 });
914
915 const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
916 expect(expanded.map((entry) => entry.type)).toEqual([
917 "message",
918 "message",
919 "run-fold",
920 "work-toggle",
921 "message",
922 "message",
923 ]);
924 expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } });
925 });
926
927 it("does not fold a response that only has opening and final messages", () => {
928 const feed = buildThreadFeed([
929 projected(userMessage(), 0),
930 projected(
931 {
932 ...assistantMessage("2026-06-20T00:00:02.000Z"),
933 id: TurnItemId.make("item-opening"),
934 messageId: MessageId.make("message-opening"),
935 text: "The result is ready.",
936 },
937 1,
938 ),
939 projected(assistantMessage(), 2),
940 ]);
941
942 const presented = deriveThreadFeedPresentation(feed, null, new Set());
943 expect(presented.map((entry) => entry.id)).toEqual([
944 "message-user",
945 "message-opening",
946 "message-assistant",
947 ]);
948 });
949
950 it("folds subagents while keeping created-thread and fork cards visible", () => {
951 const { providerThreadId: _providerThreadId, ...forkBase } = base(
952 "item-fork",
953 "2026-06-20T00:00:02.000Z",
954 2,
955 );
956 const resourceItems = [
957 {
958 ...base("item-subagent", "2026-06-20T00:00:01.500Z", 1),
959 type: "subagent",
960 subagentId: NodeId.make("child-agent"),
961 origin: "app_owned",
962 driver: ProviderDriverKind.make("codex"),
963 providerInstanceId: ProviderInstanceId.make("codex"),
964 childThreadId: sourceThreadId,
965 prompt: "Inspect the deployment configuration",
966 result: "Configuration is valid",
967 },
968 {
969 ...forkBase,
970 type: "fork",
971 source: { type: "run", threadId, runId },
972 targetThreadId: sourceThreadId,
973 },
974 {
975 ...base("item-created-thread", "2026-06-20T00:00:04.000Z", 4),
976 type: "thread_created",
977 targetThreadId: sourceThreadId,
978 targetRunId: null,
979 targetProviderInstanceId: ProviderInstanceId.make("codex"),
980 targetModel: "gpt-5.4",
981 },
982 ] satisfies ReadonlyArray<OrchestrationV2TurnItem>;
983 const projectedResources = [
984 projected(resourceItems[0]!, 1),
985 projected(resourceItems[1]!, 2),
986 projected(resourceItems[2]!, 4),
987 ];
988 const feed = buildThreadFeed([
989 projected(userMessage(), 0),
990 projectedResources[0]!,
991 projectedResources[1]!,
992 projected(command("2026-06-20T00:00:03.000Z"), 3),
993 projectedResources[2]!,
994 projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5),
995 ]);
996
997 const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
998 expect(collapsed.map((entry) => entry.type)).toEqual([
999 "message",
1000 "run-fold",
1001 "activity-group",
1002 "activity-group",
1003 "message",
1004 ]);
1005 expect(collapsed[1]).toMatchObject({
1006 type: "run-fold",
1007 createdAt: "2026-06-20T00:00:01.500Z",
1008 });
1009 expect(
1010 collapsed.flatMap((entry) =>
1011 entry.type === "activity-group"
1012 ? entry.activities.map((activity) => activity.projectedItem)
1013 : [],
1014 ),
1015 ).toEqual(projectedResources.slice(1));
1016 const expanded = deriveThreadFeedPresentation(feed, null, new Set([runId]));
1017 expect(
1018 expanded.some(
1019 (entry) =>
1020 entry.type === "activity-group" &&
1021 entry.activities.some((activity) => activity.projectedItem.item.type === "subagent"),
1022 ),
1023 ).toBe(true);
1024 });
1025
1026 it("keeps a settled run's still-running subagents visible while its other work folds", () => {
1027 const subagent = (
1028 id: string,
1029 updatedAt: string,
1030 ordinal: number,
1031 status: OrchestrationV2TurnItem["status"],
1032 ): OrchestrationV2TurnItem => ({
1033 ...base(id, updatedAt, ordinal),
1034 providerTurnId: ProviderTurnId.make("provider-turn-1"),
1035 status,
1036 completedAt: status === "running" ? null : DateTime.makeUnsafe(updatedAt),
1037 type: "subagent",
1038 subagentId: NodeId.make(id),
1039 origin: "app_owned",
1040 driver: ProviderDriverKind.make("codex"),
1041 providerInstanceId: ProviderInstanceId.make("codex"),
1042 childThreadId: sourceThreadId,
1043 prompt: `Inspect ${id}`,
1044 result: null,
1045 });
1046 const latestRun = {
1047 runId,
1048 status: "completed" as const,
1049 startedAt: "2026-06-20T00:00:01.000Z",
1050 completedAt: "2026-06-20T00:00:05.000Z",
1051 };
1052 const present = (children: ReadonlyArray<OrchestrationV2TurnItem>) =>
1053 deriveThreadFeedPresentation(
1054 buildThreadFeed([
1055 projected(userMessage(), 0),
1056 projected(command("2026-06-20T00:00:02.000Z"), 1),
1057 ...children.map((child, index) => projected(child, index + 2)),
1058 projected(assistantMessage("2026-06-20T00:00:05.000Z"), 5),
1059 ]),
1060 latestRun,
1061 new Set(),
1062 );
1063 const visibleSubagentIds = (presented: ReadonlyArray<ThreadFeedEntry>) =>
1064 presented.flatMap((entry) =>
1065 entry.type === "activity-group"
1066 ? entry.activities.flatMap((activity) =>
1067 activity.projectedItem.item.type === "subagent"
1068 ? [activity.projectedItem.item.id]
1069 : [],
1070 )
1071 : [],
1072 );
1073
1074 const live = present([subagent("item-live", "2026-06-20T00:00:03.000Z", 2, "running")]);
1075 expect(live.map((entry) => entry.type)).toEqual([
1076 "message",
1077 "run-fold",
1078 "activity-group",
1079 "message",
1080 ]);
1081 expect(visibleSubagentIds(live)).toEqual(["item-live"]);
1082
1083 // A launch batch stays whole while any member is live.
1084 const mixed = present([
1085 subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed"),
1086 subagent("item-live", "2026-06-20T00:00:04.000Z", 3, "running"),
1087 ]);
1088 expect(visibleSubagentIds(mixed)).toEqual(["item-done", "item-live"]);
1089
1090 const finished = present([subagent("item-done", "2026-06-20T00:00:03.000Z", 2, "completed")]);
1091 expect(finished.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
1092 });
1093
1094 it("folds settled V2 run work while keeping the terminal assistant message visible", () => {
1095 const feed = buildThreadFeed([
1096 projected(userMessage(), 0),
1097 projected(command(), 1),
1098 projected(assistantMessage(), 2),
1099 ]);
1100 const latestRun = {
1101 runId,
1102 status: "completed" as const,
1103 startedAt: "2026-06-20T00:00:01.000Z",
1104 completedAt: "2026-06-20T00:00:03.000Z",
1105 };
1106
1107 const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
1108 expect(collapsed.map((entry) => entry.type)).toEqual(["message", "run-fold", "message"]);
1109
1110 const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
1111 expect(expanded.map((entry) => entry.type)).toEqual([
1112 "message",
1113 "run-fold",
1114 "work-toggle",
1115 "message",
1116 ]);
1117 });
1118
1119 it("keeps an active run expanded and detects failures from completed command output", () => {
1120 const failedCommand: OrchestrationV2TurnItem = {
1121 ...command(),
1122 output: "sh: missing-command: command not found",
1123 };
1124 const feed = buildThreadFeed([projected(userMessage(), 0), projected(failedCommand, 1)]);
1125 const presented = deriveThreadFeedPresentation(
1126 feed,
1127 {
1128 runId,
1129 status: "running",
1130 startedAt: "2026-06-20T00:00:01.000Z",
1131 completedAt: null,
1132 },
1133 new Set(),
1134 );
1135
1136 expect(presented.some((entry) => entry.type === "run-fold")).toBe(false);
1137 expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
1138 summary: "vp check",
1139 hiddenCount: 1,
1140 hasFailure: true,
1141 live: false,
1142 });
1143 });
1144
1145 it("folds each run of a provider-native subagent thread like a normal turn", () => {
1146 // A Claude subagent's child thread, as projected: no runs, and a user
1147 // prompt for the launch and for a SendMessage resume.
1148 const runless = <T extends OrchestrationV2TurnItem>(item: T, id: string, ordinal: number) => ({
1149 ...item,
1150 id: TurnItemId.make(id),
1151 runId: null,
1152 ordinal,
1153 });
1154 const prompt = (id: string, ordinal: number, at: string) =>
1155 runless(
1156 { ...userMessage(at), messageId: MessageId.make(id), creationSource: "provider" as const },
1157 id,
1158 ordinal,
1159 );
1160 const answer = (id: string, ordinal: number, at: string) =>
1161 runless({ ...assistantMessage(at), messageId: MessageId.make(id) }, id, ordinal);
1162 const { exitCode: _exitCode, ...completedCommand } = command("2026-06-20T00:01:17.000Z");
1163 const feed = (resumeRunning: boolean) =>
1164 buildThreadFeed(
1165 [
1166 prompt("launch", 1, "2026-06-20T00:00:00.000Z"),
1167 runless(command("2026-06-20T00:00:04.000Z"), "launch-ls", 2),
1168 answer("launch-answer", 3, "2026-06-20T00:00:08.000Z"),
1169 prompt("resume", 4, "2026-06-20T00:01:12.000Z"),
1170 resumeRunning
1171 ? runless(
1172 { ...completedCommand, status: "running", completedAt: null, output: "" },
1173 "resume-ls",
1174 5,
1175 )
1176 : runless(command("2026-06-20T00:01:17.000Z"), "resume-ls", 5),
1177 ...(resumeRunning ? [] : [answer("resume-answer", 6, "2026-06-20T00:01:20.000Z")]),
1178 ].map((item, position) => projected(item, position)),
1179 );
1180 const shape = (entries: ReadonlyArray<ThreadFeedEntry>) =>
1181 entries.map((entry) =>
1182 entry.type === "run-fold"
1183 ? `fold:${entry.label}`
1184 : entry.type === "message"
1185 ? `${entry.message.role}:${entry.message.id}`
1186 : entry.type,
1187 );
1188
1189 const settled = deriveThreadFeedPresentation(feed(false), null, new Set());
1190 expect(shape(settled)).toEqual([
1191 "user:launch",
1192 "fold:Worked for 8.0s",
1193 "assistant:launch-answer",
1194 "user:resume",
1195 "fold:Worked for 8.0s",
1196 "assistant:resume-answer",
1197 ]);
1198 const launchFold = settled.find((entry) => entry.type === "run-fold");
1199 if (launchFold?.type !== "run-fold") throw new Error("Expected the launch fold");
1200 expect(
1201 shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))),
1202 ).toEqual([
1203 "user:launch",
1204 "fold:Worked for 8.0s",
1205 "work-toggle",
1206 "assistant:launch-answer",
1207 "user:resume",
1208 "fold:Worked for 8.0s",
1209 "assistant:resume-answer",
1210 ]);
1211
1212 // While the resume runs, only the settled launch folds.
1213 expect(
1214 shape(
1215 deriveThreadFeedPresentation(
1216 feed(true),
1217 null,
1218 new Set(),
1219 new Set(),
1220 "2026-06-20T00:01:12.000Z",
1221 true,
1222 ),
1223 ),
1224 ).toEqual([
1225 "user:launch",
1226 "fold:Worked for 8.0s",
1227 "assistant:launch-answer",
1228 "user:resume",
1229 "work-toggle",
1230 ]);
1231 });
1232
1233 it("keeps imported V1 turns folded once the thread's first V2 run starts", () => {
1234 const imported = <T extends OrchestrationV2TurnItem>(item: T, id: string) => ({
1235 ...item,
1236 id: TurnItemId.make(id),
1237 runId: null,
1238 });
1239 const presented = (start: OrchestrationV2TurnItem) =>
1240 deriveThreadFeedPresentation(
1241 buildThreadFeed(
1242 [
1243 imported(userMessage("2026-06-20T00:00:00.000Z"), "imported-prompt"),
1244 imported(
1245 {
1246 ...assistantMessage("2026-06-20T00:00:02.000Z"),
1247 messageId: MessageId.make("update"),
1248 },
1249 "imported-update",
1250 ),
1251 imported(command("2026-06-20T00:00:04.000Z"), "imported-ls"),
1252 imported(
1253 {
1254 ...assistantMessage("2026-06-20T00:00:08.000Z"),
1255 messageId: MessageId.make("answer"),
1256 },
1257 "imported-answer",
1258 ),
1259 start,
1260 ].map((item, position) => projected(item, position)),
1261 ),
1262 { runId, status: "running", startedAt: "2026-06-20T00:01:00.000Z", completedAt: null },
1263 new Set(),
1264 new Set(),
1265 "2026-06-20T00:01:00.000Z",
1266 )
1267 .slice(0, 4)
1268 .map((entry) => (entry.type === "message" ? entry.message.role : entry.type));
1269
1270 // A sent prompt and an automatic wake both start V2 work below the import.
1271 expect(
1272 presented({
1273 ...userMessage("2026-06-20T00:01:00.000Z"),
1274 id: TurnItemId.make("new-prompt"),
1275 messageId: MessageId.make("new-prompt"),
1276 }),
1277 ).toEqual(["user", "assistant", "run-fold", "assistant"]);
1278 expect(
1279 presented({
1280 ...base("wake", "2026-06-20T00:01:00.000Z", 4),
1281 type: "notification",
1282 source: { kind: "background_task" },
1283 outcome: "completed",
1284 summary: "Background task finished",
1285 }),
1286 ).toEqual(["user", "assistant", "run-fold", "assistant"]);
1287 });
1288
1289 it("keeps a provider-native subagent's runless tool call live while it works", () => {
1290 const startedAt = "2026-06-20T00:00:01.000Z";
1291 const { exitCode: _exitCode, ...completedCommand } = command();
1292 const runningCommand: OrchestrationV2TurnItem = {
1293 ...completedCommand,
1294 runId: null,
1295 status: "running",
1296 completedAt: null,
1297 output: "",
1298 };
1299 const feed = buildThreadFeed([
1300 projected({ ...userMessage(), runId: null }, 0),
1301 projected(runningCommand, 1),
1302 ]);
1303
1304 const presented = deriveThreadFeedPresentation(
1305 feed,
1306 null,
1307 new Set(),
1308 new Set(),
1309 startedAt,
1310 true,
1311 );
1312 expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
1313 summary: "Running vp",
1314 live: true,
1315 shimmer: true,
1316 });
1317 expect(presented.some((entry) => entry.type === "thinking")).toBe(false);
1318 });
1319
1320 it("keeps a runless tail folded while a normal thread waits for its sent run", () => {
1321 // Right after a send the local clock runs before the server creates the
1322 // run, and the latest run may still be queued: neither is runless work,
1323 // so the settled tail must not reopen and shift the feed.
1324 const startedAt = "2026-06-20T00:00:05.000Z";
1325 const feed = buildThreadFeed([
1326 projected({ ...userMessage(), runId: null }, 0),
1327 projected({ ...command(), runId: null }, 1),
1328 ]);
1329 for (const latestRun of [
1330 null,
1331 { runId, status: "queued" as const, startedAt: null, completedAt: null },
1332 ]) {
1333 const presented = deriveThreadFeedPresentation(
1334 feed,
1335 latestRun,
1336 new Set(),
1337 new Set(),
1338 startedAt,
1339 );
1340 expect(presented.map((entry) => entry.type)).toEqual(["message", "run-fold", "thinking"]);
1341 }
1342 });
1343
1344 it("waits for workspace preparation before showing provider activity", () => {
1345 const startedAt = "2026-04-01T00:00:01.000Z";
1346 const run = { runId, status: "preparing" as const, startedAt: null, completedAt: null };
1347 expect(deriveThreadFeedPresentation([], run, new Set(), new Set(), startedAt)).toEqual([]);
1348 expect(
1349 deriveThreadFeedPresentation(
1350 [],
1351 { ...run, status: "running", startedAt },
1352 new Set(),
1353 new Set(),
1354 startedAt,
1355 ),
1356 ).toEqual([{ type: "thinking", id: "live-activity-row", createdAt: startedAt, runId }]);
1357 });
1358
1359 it("uses a stable Thinking row while work has started without a projected item", () => {
1360 const startedAt = "2026-04-01T00:00:01.000Z";
1361 const presented = deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt);
1362
1363 expect(presented).toEqual([
1364 { type: "thinking", id: "live-activity-row", createdAt: startedAt, runId: null },
1365 ]);
1366 expect(deriveThreadFeedPresentation([], null, new Set(), new Set(), startedAt)[0]).toBe(
1367 presented[0],
1368 );
1369 });
1370
1371 it("keeps expanded work in one group with stable row identities", () => {
1372 const activity = (
1373 id: string,
1374 createdAt: string,
1375 status: ThreadFeedActivity["status"] = "success",
1376 ): ThreadFeedActivity => ({
1377 id,
1378 createdAt,
1379 runId: null,
1380 attemptId: null,
1381 summary: `Tool ${id}`,
1382 detail: null,
1383 canExpand: false,
1384 fetchesDetail: false,
1385 getFullDetail: () => null,
1386 getCopyText: () => id,
1387 icon: "command",
1388 logo: null,
1389 toolLike: true,
1390 prominent: false,
1391 status,
1392 lifecycleStatus: status === "neutral" ? "inProgress" : "completed",
1393 workEntry: {
1394 id,
1395 createdAt,
1396 label: `Tool ${id}`,
1397 tone: "tool",
1398 command: "vp check",
1399 itemType: "command_execution",
1400 toolLifecycleStatus: status === "neutral" ? "inProgress" : "completed",
1401 },
1402 projectedItem: projected(command(createdAt), 0),
1403 });
1404 const feed: ThreadFeedEntry[] = [
1405 {
1406 type: "activity-group",
1407 id: "work-group-1",
1408 createdAt: "2026-04-01T00:00:01.000Z",
1409 runId: null,
1410 activities: [
1411 activity("activity-neutral", "2026-04-01T00:00:01.000Z", "neutral"),
1412 activity("activity-1", "2026-04-01T00:00:02.000Z"),
1413 activity("activity-2", "2026-04-01T00:00:03.000Z"),
1414 activity("activity-3", "2026-04-01T00:00:04.000Z"),
1415 ],
1416 },
1417 ];
1418
1419 const collapsed = deriveThreadFeedPresentation(feed, null, new Set());
1420 expect(collapsed.map((entry) => entry.id)).toEqual(["work-toggle:work-group:activity-neutral"]);
1421 expect(collapsed[0]).toMatchObject({
1422 type: "work-toggle",
1423 groupId: "work-group:activity-neutral",
1424 hiddenCount: 3,
1425 expanded: false,
1426 summary: "Ran 3 commands",
1427 });
1428
1429 const expanded = deriveThreadFeedPresentation(
1430 feed,
1431 null,
1432 new Set(),
1433 new Set(["work-group:activity-neutral"]),
1434 );
1435 expect(expanded.map((entry) => entry.id)).toEqual([
1436 "work-toggle:work-group:activity-neutral",
1437 "work-details:work-group:activity-neutral",
1438 ]);
1439 expect(expanded[0]).toMatchObject({
1440 type: "work-toggle",
1441 expanded: true,
1442 });
1443 expect(expanded[1]).toMatchObject({
1444 type: "activity-group",
1445 activities: [
1446 { id: "activity-1", groupedToolDetail: true, live: false },
1447 { id: "activity-2", groupedToolDetail: true, live: false },
1448 { id: "activity-3", groupedToolDetail: true, live: false },
1449 ],
1450 });
1451 });
1452
1453 it("retains Claude Read image previews without tool output", () => {
1454 const item = {
1455 ...base("image-read", "2026-06-20T00:00:04.000Z", 3),
1456 type: "dynamic_tool" as const,
1457 toolName: "Read",
1458 input: { file_path: "/workspace/reference.png" },
1459 viewedImagePath: "/workspace/reference.png",
1460 } satisfies OrchestrationV2TurnItem;
1461 const feed = buildThreadFeed([projected(item, 0)]);
1462 const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
1463 expect(activity?.workEntry.viewedImagePath).toBe("/workspace/reference.png");
1464 });
1465
1466 it("pretty prints T3 MCP dynamic tool activities and attaches the product logo", () => {
1467 const toolItem: OrchestrationV2TurnItem = {
1468 ...base("item-t3-tool", "2026-06-20T00:00:04.000Z", 3),
1469 type: "dynamic_tool",
1470 toolName: "mcp__t3-code__t3_thread_read",
1471 input: { threadId: "thread-child" },
1472 output: { messages: [] },
1473 };
1474
1475 const feed = buildThreadFeed([projected(toolItem, 0)]);
1476 const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
1477
1478 expect(activity?.summary).toBe("Read a T3 thread");
1479 expect(activity?.logo).toBe("t3-code");
1480 expect(activity?.getCopyText().split("\n")[0]).toBe("Read a T3 thread");
1481 });
1482
1483 it.each(["cua_repl.js", "node_repl.js", "js"])(
1484 "uses the %s action title in the mobile feed",
1485 (toolName) => {
1486 const item: OrchestrationV2TurnItem = {
1487 ...base("cua", "2026-09-23T20:20:00.000Z", 1),
1488 type: "dynamic_tool",
1489 toolName,
1490 input: {
1491 code: "await game.getAXStateAndScreenshot();",
1492 title: "Inspect Saga music screen",
1493 },
1494 };
1495 const feed = buildThreadFeed([projected(item, 0)]);
1496 const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
1497 expect(activity?.summary).toBe("Inspect Saga music screen");
1498 },
1499 );
1500
1501 it.each([
1502 [
1503 "failed",
1504 "await sky.type_text({app:'T3 Code (Nightly)',text:'debug(ga.run)'});",
1505 "Restrict the breakpoint to Git actions",
1506 "computer",
1507 ],
1508 [
1509 "completed",
1510 "await fetch('http://127.0.0.1/json/list');",
1511 "Discover the live Electron debugging endpoint",
1512 "code",
1513 ],
1514 ] as const)("recovers the retained js title and %s call icon", (status, code, title, icon) => {
1515 const item: OrchestrationV2TurnItem = {
1516 ...base("retained-js", "2026-09-23T20:20:00.000Z", 1),
1517 type: "dynamic_tool",
1518 status,
1519 title: "js",
1520 toolName: "node_repl.js",
1521 toolSource: { key: "mcp:node_repl", name: "node repl", kind: "integration" },
1522 input: { code, title },
1523 };
1524 const feed = buildThreadFeed([projected(item, 0)]);
1525 const activity = feed[0]?.type === "activity-group" ? feed[0].activities[0] : null;
1526 expect(activity?.summary).toBe(title);
1527 expect(activity?.icon).toBe(icon);
1528 expect(activity?.workEntry.toolSource?.kind).toBe(
1529 icon === "computer" ? "computer" : "integration",
1530 );
1531 });
1532
1533 it("uses canonical T3 orchestration summaries in compact work groups", () => {
1534 const rows = [
1535 projected(command("2026-06-20T00:00:01.000Z"), 0),
1536 ...["mcp__t3-code__t3_thread_send", "t3_code.t3_thread_send", "t3_thread_send"].map(
1537 (toolName, index) =>
1538 projected(
1539 {
1540 ...base(`item-send-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 2),
1541 type: "dynamic_tool" as const,
1542 toolName,
1543 input: { threadId: `thread-${index}`, message: "Continue" },
1544 output: { threadId: `thread-${index}`, messageId: `message-${index}` },
1545 },
1546 index + 1,
1547 ),
1548 ),
1549 projected(
1550 {
1551 ...command("2026-06-20T00:00:06.000Z"),
1552 id: TurnItemId.make("item-command-2"),
1553 ordinal: 6,
1554 },
1555 4,
1556 ),
1557 ];
1558
1559 const presented = deriveThreadFeedPresentation(
1560 buildThreadFeed(rows),
1561 { runId, status: "running", startedAt: null, completedAt: null },
1562 new Set(),
1563 );
1564
1565 expect(presented).toMatchObject([
1566 {
1567 type: "work-toggle",
1568 summary: "Ran 2 commands and sent messages to 3 threads",
1569 hiddenCount: 5,
1570 hasFailure: false,
1571 },
1572 ]);
1573 });
1574
1575 it("presents project calls and summarizes successful clones through the mobile feed", () => {
1576 const items: OrchestrationV2TurnItem[] = [
1577 {
1578 ...base("list", "2026-09-19T00:00:01.000Z", 1),
1579 type: "dynamic_tool",
1580 title: "Custom provider title",
1581 toolName: "T3-code.t3_project_list",
1582 input: {},
1583 output: { projects: [] },
1584 },
1585 {
1586 ...base("clone", "2026-09-19T00:00:02.000Z", 2),
1587 type: "dynamic_tool",
1588 title: "Custom provider title",
1589 toolName: "mcp__t3_code__t3_project_clone",
1590 input: {},
1591 output: { cwd: "/tmp/repo" },
1592 },
1593 {
1594 ...base("failed-clone", "2026-09-19T00:00:03.000Z", 3),
1595 type: "dynamic_tool",
1596 title: "Custom provider title",
1597 toolName: "t3_project_clone",
1598 input: {},
1599 output: { isError: true },
1600 },
1601 ];
1602 const feed = buildThreadFeed(items.map((item, position) => projected(item, position)));
1603 const activities = feed.flatMap((entry) =>
1604 entry.type === "activity-group" ? entry.activities : [],
1605 );
1606 expect(workEntryRowLabel(activities[0]!.workEntry)).toBe("Listed projects");
1607 expect(workEntryRowLabel(activities[1]!.workEntry)).toBe("Cloned a repository");
1608 expect(workEntryRowLabel(activities[2]!.workEntry)).toBe("Failed to clone a repository");
1609 expect(activities.every((activity) => activity.logo === "t3-code")).toBe(true);
1610 const presented = deriveThreadFeedPresentation(
1611 feed,
1612 { runId, status: "running", startedAt: null, completedAt: null },
1613 new Set(),
1614 );
1615 expect(presented.find((entry) => entry.type === "work-toggle")).toMatchObject({
1616 summary: "Listed projects 1 time and cloned 1 repository",
1617 hasFailure: true,
1618 });
1619 });
1620});
1621
1622describe("retained v2 feed presentation", () => {
1623 it("retains unchanged rows while the assistant streams", () => {
1624 const rows = [
1625 projected(userMessage(), 0),
1626 projected(command(), 1),
1627 projected({ ...assistantMessage(), streaming: true }, 2),
1628 ];
1629 const latestRun = {
1630 runId,
1631 status: "running" as const,
1632 startedAt: "2026-06-20T00:00:01.000Z",
1633 completedAt: null,
1634 };
1635 const before = buildThreadFeed(rows);
1636 const beforePresentation = deriveThreadFeedPresentation(
1637 before,
1638 latestRun,
1639 new Set(),
1640 new Set(),
1641 latestRun.startedAt,
1642 );
1643 const after = buildThreadFeed([
1644 rows[0]!,
1645 rows[1]!,
1646 projected(
1647 { ...assistantMessage("2026-06-20T00:00:04.000Z"), text: "Still working", streaming: true },
1648 2,
1649 ),
1650 ]);
1651 const afterPresentation = deriveThreadFeedPresentation(
1652 after,
1653 latestRun,
1654 new Set(),
1655 new Set(),
1656 latestRun.startedAt,
1657 );
1658 expect(after[0]).toBe(before[0]);
1659 expect(after[1]).toBe(before[1]);
1660 expect(after[2]).not.toBe(before[2]);
1661 expect(afterPresentation[0]).toBe(beforePresentation[0]);
1662 expect(afterPresentation[1]).toBe(beforePresentation[1]);
1663 });
1664
1665 it.each(["running", "completed", "interrupted"] as const)(
1666 "uses the compaction row as the live activity only while %s",
1667 (status) => {
1668 const compact = projected(
1669 {
1670 ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
1671 type: "compaction",
1672 status,
1673 driver: null,
1674 beforeTokenCount: 899_000,
1675 ...(status === "completed" ? { afterTokenCount: 19_000 } : {}),
1676 },
1677 1,
1678 );
1679 const latestRun = {
1680 runId,
1681 status: "running" as const,
1682 startedAt: "2026-06-20T00:00:01.000Z",
1683 completedAt: null,
1684 };
1685 const rows = deriveThreadFeedPresentation(
1686 buildThreadFeed([projected(userMessage(), 0), compact]),
1687 latestRun,
1688 new Set(),
1689 new Set(),
1690 latestRun.startedAt,
1691 );
1692 expect(rows.some((row) => row.type === "thinking")).toBe(status !== "running");
1693 expect(rows.find((row) => row.type === "activity-group")).toMatchObject({
1694 activities: [
1695 {
1696 summary:
1697 status === "running"
1698 ? "Compacting context"
1699 : status === "completed"
1700 ? "Context compacted 899K → 19K tokens"
1701 : "Context compaction stopped",
1702 },
1703 ],
1704 });
1705 },
1706 );
1707
1708 it.each(["running", "completed", "failed"] as const)(
1709 "keeps a %s handoff separate from commands and visible through folds",
1710 (status) => {
1711 const handoff = projected(
1712 {
1713 ...base("handoff", "2026-06-20T00:00:02.000Z", 1),
1714 type: "handoff",
1715 status,
1716 contextHandoffId: ContextHandoffId.make("handoff"),
1717 fromProviderThreadIds: [],
1718 toProviderThreadId: ProviderThreadId.make("target"),
1719 fromProviderInstanceIds: [ProviderInstanceId.make("codex")],
1720 toProviderInstanceId: ProviderInstanceId.make("claudeAgent"),
1721 strategy: "full_thread_summary",
1722 summary: "Private full conversation summary",
1723 },
1724 1,
1725 );
1726 const feed = buildThreadFeed([
1727 projected(userMessage(), 0),
1728 handoff,
1729 projected(command("2026-06-20T00:00:03.000Z"), 2),
1730 projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3),
1731 ]);
1732 for (const expanded of [new Set<RunId>(), new Set([runId])]) {
1733 const rows = deriveThreadFeedPresentation(feed, null, expanded);
1734 const divider = rows.filter(
1735 (entry) => entry.type === "activity-group" && isContextHandoffActivityGroup(entry),
1736 );
1737 expect(divider).toHaveLength(1);
1738 expect(divider[0]).toMatchObject({ activities: [{ projectedItem: handoff }] });
1739 }
1740 const alone = deriveThreadFeedPresentation(
1741 buildThreadFeed([projected(userMessage(), 0), handoff]),
1742 null,
1743 new Set(),
1744 );
1745 expect(alone.map((entry) => entry.type)).toEqual(["message", "activity-group"]);
1746 },
1747 );
1748
1749 it("keeps a standalone compaction visible and folds it with other completed work", () => {
1750 const compact = projected(
1751 {
1752 ...base("compacted", "2026-06-20T00:00:02.000Z", 1),
1753 type: "compaction",
1754 driver: null,
1755 summary: "Shorter context",
1756 },
1757 1,
1758 );
1759 const latestRun = {
1760 runId,
1761 status: "completed" as const,
1762 startedAt: "2026-06-20T00:00:01.000Z",
1763 completedAt: "2026-06-20T00:00:04.000Z",
1764 };
1765 const onlyCompaction = deriveThreadFeedPresentation(
1766 buildThreadFeed([projected(userMessage(), 0), compact]),
1767 latestRun,
1768 new Set(),
1769 );
1770 expect(onlyCompaction.map((entry) => entry.type)).toEqual(["message", "activity-group"]);
1771 const feed = buildThreadFeed([
1772 projected(userMessage(), 0),
1773 compact,
1774 projected(command("2026-06-20T00:00:03.000Z"), 2),
1775 projected(assistantMessage("2026-06-20T00:00:04.000Z"), 3),
1776 ]);
1777 expect(
1778 deriveThreadFeedPresentation(feed, latestRun, new Set()).map((entry) => entry.type),
1779 ).toEqual(["message", "run-fold", "message"]);
1780 const expanded = deriveThreadFeedPresentation(feed, latestRun, new Set([runId]));
1781 expect(
1782 expanded.find(
1783 (entry) =>
1784 entry.type === "activity-group" &&
1785 entry.activities[0]?.projectedItem.item.type === "compaction",
1786 ),
1787 ).toMatchObject({ activities: [{ summary: "Context compacted" }] });
1788 });
1789
1790 it("retains assistant image attachments from the wire", () => {
1791 const image = {
1792 type: "image" as const,
1793 id: "assistant-image",
1794 name: "result.png",
1795 mimeType: "image/png",
1796 sizeBytes: 100,
1797 };
1798 const feed = buildThreadFeed([
1799 projected({ ...assistantMessage(), text: "", attachments: [image] }, 0),
1800 ]);
1801 expect(feed).toMatchObject([
1802 { type: "message", message: { role: "assistant", attachments: [image] } },
1803 ]);
1804 });
1805
1806 it("keeps native application icons and source identity in collapsed and expanded work", () => {
1807 const icon = {
1808 _tag: "native-app" as const,
1809 app: { _tag: "app-id" as const, appId: "com.example.Editor" },
1810 };
1811 const source = {
1812 key: "native-app:com.example.editor",
1813 name: "Editor",
1814 kind: "computer" as const,
1815 icon,
1816 };
1817 const rows = [0, 1].map((index) =>
1818 projected(
1819 {
1820 ...base(`native-${index}`, `2026-06-20T00:00:0${index + 2}.000Z`, index + 1),
1821 type: "dynamic_tool" as const,
1822 toolName: "computer.click",
1823 input: { x: index, y: 1 },
1824 output: null,
1825 toolSurface: "computer" as const,
1826 toolIcon: icon,
1827 toolSource: source,
1828 },
1829 index,
1830 ),
1831 );
1832 const feed = buildThreadFeed(rows);
1833 const latestRun = { runId, status: "running" as const, startedAt: null, completedAt: null };
1834 const collapsed = deriveThreadFeedPresentation(feed, latestRun, new Set());
1835 const toggle = collapsed[0];
1836 if (toggle?.type !== "work-toggle") throw new Error("Expected a collapsed work group");
1837 const presented = deriveThreadFeedPresentation(
1838 feed,
1839 latestRun,
1840 new Set(),
1841 new Set([toggle.groupId]),
1842 );
1843 expect(presented[0]).toMatchObject({
1844 type: "work-toggle",
1845 summary: "Used Editor",
1846 toolSurface: "computer",
1847 toolIcon: icon,
1848 });
1849 expect(presented[1]).toMatchObject({
1850 type: "activity-group",
1851 activities: [
1852 { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
1853 { icon: "computer", workEntry: { toolSource: source, toolIcon: icon } },
1854 ],
1855 });
1856 });
1857
1858 it.each([
1859 ["failed", "Failed to click in the preview browser", true],
1860 ["cancelled", "Stopped clicking in the preview browser", false],
1861 ] as const)(
1862 "keeps %s calls terminal while the parent run remains live",
1863 (status, summary, hasFailure) => {
1864 const feed = buildThreadFeed([
1865 projected(
1866 {
1867 ...base("preview-click", "2026-06-20T00:00:02.000Z", 1),
1868 type: "dynamic_tool",
1869 status,
1870 toolName: "mcp__t3-code__preview_click",
1871 input: { element: "button" },
1872 output: null,
1873 },
1874 0,
1875 ),
1876 ]);
1877 const rows = deriveThreadFeedPresentation(
1878 feed,
1879 { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null },
1880 new Set(),
1881 new Set(),
1882 "2026-06-20T00:00:01.000Z",
1883 );
1884 expect(rows[0]).toMatchObject({ type: "work-toggle", summary, hasFailure, shimmer: false });
1885 },
1886 );
1887
1888 it.each([
1889 { envelope: "direct", output: { taskId: "a" } },
1890 { envelope: "structured", output: { structuredContent: { taskId: "a" } } },
1891 { envelope: "text", output: { content: [{ type: "text", text: '{"taskId":"a"}' }] } },
1892 ])(
1893 "folds matched $envelope delegations without hiding pending, failed or unmatched calls",
1894 ({ output }) => {
1895 const agent = (
1896 id: string,
1897 index: number,
1898 origin = "app_owned" as "app_owned" | "provider_native",
1899 ) =>
1900 projected(
1901 {
1902 ...base(id, "2026-06-20T00:00:01.000Z", index),
1903 type: "subagent",
1904 subagentId: NodeId.make(id),
1905 origin,
1906 driver: ProviderDriverKind.make("codex"),
1907 providerInstanceId: ProviderInstanceId.make("codex"),
1908 childThreadId: ThreadId.make(`child-${id}`),
1909 prompt: "Identical task",
1910 result: "Done",
1911 },
1912 index,
1913 );
1914 const delegation = (
1915 id: string,
1916 index: number,
1917 overrides: Partial<Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>> = {},
1918 ) =>
1919 projected(
1920 {
1921 ...base(id, "2026-06-20T00:00:02.000Z", index),
1922 type: "dynamic_tool",
1923 toolName: "t3-code.delegate_task",
1924 input: { task: "Identical task" },
1925 output,
1926 ...overrides,
1927 },
1928 index,
1929 );
1930 const feed = buildThreadFeed([
1931 agent("a", 1),
1932 delegation("matched", 2),
1933 agent("b", 3),
1934 delegation("pending", 4, { status: "running", output: null }),
1935 delegation("unmatched", 5, { output: { taskId: "missing" } }),
1936 delegation("failed", 6, { status: "failed" }),
1937 delegation("error-output", 7, { output: { taskId: "a", isError: true } }),
1938 delegation("other-run", 8, { runId: RunId.make("other-run") }),
1939 agent("native", 9, "provider_native"),
1940 delegation("native-delegation", 10, { output: { taskId: "native" } }),
1941 ]);
1942 const groups = feed.flatMap((entry) =>
1943 entry.type === "activity-group"
1944 ? [entry.activities.map((activity) => activity.projectedItem.item.id)]
1945 : [],
1946 );
1947 expect(groups[0]).toEqual(["a", "b"]);
1948 expect(groups.flat()).toEqual([
1949 "a",
1950 "b",
1951 "pending",
1952 "unmatched",
1953 "failed",
1954 "error-output",
1955 "other-run",
1956 "native",
1957 "native-delegation",
1958 ]);
1959 const presented = deriveThreadFeedPresentation(
1960 feed,
1961 null,
1962 new Set([runId, RunId.make("other-run")]),
1963 );
1964 expect(
1965 presented.find(
1966 (entry) =>
1967 entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.id === "a",
1968 )?.continuesWorkLog,
1969 ).toBeUndefined();
1970 },
1971 );
1972
1973 it("keeps subagents from different provider turns in separate cards", () => {
1974 const agent = (id: string, index: number) =>
1975 projected(
1976 {
1977 ...base(id, "2026-06-20T00:00:01.000Z", index),
1978 type: "subagent",
1979 subagentId: NodeId.make(id),
1980 origin: "provider_native",
1981 driver: ProviderDriverKind.make("codex"),
1982 providerInstanceId: ProviderInstanceId.make("codex"),
1983 providerTurnId: ProviderTurnId.make(id),
1984 childThreadId: null,
1985 prompt: "Task",
1986 result: null,
1987 },
1988 index,
1989 );
1990 expect(
1991 buildThreadFeed([agent("a", 1), agent("b", 2)]).flatMap((entry) =>
1992 entry.type === "activity-group"
1993 ? [entry.activities.map((activity) => activity.projectedItem.item.id)]
1994 : [],
1995 ),
1996 ).toEqual([["a"], ["b"]]);
1997 });
1998
1999 it("groups only adjacent subagents in the same run, keeping their child links", () => {
2000 const agent = (id: string, index: number, agentRunId = runId) =>
2001 projected(
2002 {
2003 ...base(id, `2026-06-20T00:00:0${index}.000Z`, index),
2004 type: "subagent",
2005 runId: agentRunId,
2006 subagentId: NodeId.make(id),
2007 origin: "app_owned",
2008 driver: ProviderDriverKind.make("codex"),
2009 providerInstanceId: ProviderInstanceId.make("codex"),
2010 childThreadId: ThreadId.make(`child-${id}`),
2011 prompt: "Solve the puzzle",
2012 result: "Done",
2013 },
2014 index,
2015 );
2016 const feed = buildThreadFeed([
2017 agent("a", 1),
2018 agent("b", 2),
2019 projected(command("2026-06-20T00:00:03.000Z"), 3),
2020 agent("c", 4),
2021 agent("d", 5, RunId.make("other-run")),
2022 ]);
2023 expect(
2024 feed.flatMap((entry) =>
2025 entry.type === "activity-group"
2026 ? [entry.activities.map((activity) => activity.projectedItem.item.id)]
2027 : [],
2028 ),
2029 ).toEqual([["a", "b"], ["item-command"], ["c"], ["d"]]);
2030 const presented = deriveThreadFeedPresentation(
2031 feed,
2032 null,
2033 new Set([runId, RunId.make("other-run")]),
2034 );
2035 const groups = presented.flatMap((entry) =>
2036 entry.type === "activity-group" && entry.activities[0]?.projectedItem.item.type === "subagent"
2037 ? [entry]
2038 : [],
2039 );
2040 expect(groups.map((entry) => entry.activities.length)).toEqual([2, 1, 1]);
2041 expect(groups[0]?.activities.map((activity) => activity.projectedItem.item)).toMatchObject([
2042 { childThreadId: "child-a" },
2043 { childThreadId: "child-b" },
2044 ]);
2045 });
2046
2047 it("shows an idle native subagent without claiming completion", () => {
2048 const rows = buildThreadFeed([
2049 projected(
2050 {
2051 ...base("native-agent", "2026-06-20T00:00:02.000Z", 1),
2052 type: "subagent",
2053 status: "idle",
2054 subagentId: NodeId.make("native-agent"),
2055 origin: "provider_native",
2056 driver: ProviderDriverKind.make("antigravity"),
2057 providerInstanceId: ProviderInstanceId.make("antigravity"),
2058 childThreadId: null,
2059 title: "Search",
2060 prompt: "Find relevant files",
2061 result: null,
2062 },
2063 0,
2064 ),
2065 ]);
2066 expect(rows[0]).toMatchObject({
2067 type: "activity-group",
2068 activities: [{ status: "neutral", lifecycleStatus: "idle", prominent: false }],
2069 });
2070 expect(deriveThreadFeedPresentation(rows, null, new Set([runId]))).toMatchObject([
2071 { type: "run-fold", expanded: true },
2072 { type: "activity-group", activities: [{ lifecycleStatus: "idle" }] },
2073 ]);
2074 });
2075});
2076
2077const singleSelectQuestion = {
2078 id: "runtime",
2079 header: "Runtime",
2080 question: "Which runtime should be used?",
2081 options: [
2082 { label: "Go", description: "One binary" },
2083 { label: "Node.js", description: "Reuse TypeScript" },
2084 ],
2085 multiSelect: false,
2086} as const;
2087
2088const multiSelectQuestion = {
2089 id: "scope",
2090 header: "Scope",
2091 question: "Which data should be collected?",
2092 options: [
2093 { label: "Orders", description: "Receipts" },
2094 { label: "Listings", description: "Inventory" },
2095 ],
2096 multiSelect: true,
2097} as const;
2098
2099describe("subagent continued by a later turn", () => {
2100 // Stored shape from #15891: resuming a subagent moves its item to the
2101 // resuming run, but the item keeps the ordinal from the turn that started it.
2102 const run2 = RunId.make("run-2");
2103 const at = (second: number) => `2026-10-09T10:00:${String(second).padStart(2, "0")}.000Z`;
2104 const item = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2105 ...base(id, at(second), ordinal),
2106 runId: itemRunId,
2107 });
2108 const user = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2109 ...item(id, itemRunId, ordinal, second),
2110 type: "user_message" as const,
2111 messageId: MessageId.make(id),
2112 createdBy: "user" as const,
2113 creationSource: "mobile" as const,
2114 inputIntent: "turn_start" as const,
2115 text: `Prompt ${id}`,
2116 attachments: [],
2117 });
2118 const assistant = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2119 ...item(id, itemRunId, ordinal, second),
2120 type: "assistant_message" as const,
2121 messageId: MessageId.make(id),
2122 text: `Answer ${id}`,
2123 streaming: false,
2124 });
2125 const reasoning = (id: string, itemRunId: RunId, ordinal: number, second: number) => ({
2126 ...item(id, itemRunId, ordinal, second),
2127 type: "reasoning" as const,
2128 text: "Thinking",
2129 streaming: false,
2130 });
2131 const items: OrchestrationV2TurnItem[] = [
2132 user("prompt-1", runId, 1_000_001, 0),
2133 reasoning("reasoning-1", runId, 1_000_002, 1),
2134 {
2135 ...item("subagent", run2, 1_000_003, 2),
2136 type: "subagent",
2137 subagentId: NodeId.make("subagent"),
2138 origin: "provider_native",
2139 driver: ProviderDriverKind.make("claudeAgent"),
2140 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
2141 childThreadId: null,
2142 prompt: "Research the bug",
2143 result: "Found it",
2144 },
2145 assistant("answer-1", runId, 1_000_004, 3),
2146 user("prompt-2", run2, 2_000_001, 30),
2147 reasoning("reasoning-2", run2, 2_000_002, 31),
2148 assistant("answer-2", run2, 2_000_003, 32),
2149 {
2150 ...item("send-message", run2, 2_000_004, 33),
2151 type: "dynamic_tool",
2152 toolName: "SendMessage",
2153 input: { to: "subagent" },
2154 output: "Sent",
2155 },
2156 assistant("closing-2", run2, 2_000_005, 41),
2157 ];
2158
2159 it("keeps each turn's fold between its own prompt and answer", () => {
2160 const presented = deriveThreadFeedPresentation(
2161 buildThreadFeed(items.map((entry, index) => projected(entry, index))),
2162 { runId: run2, status: "completed", startedAt: at(30), completedAt: at(41) },
2163 new Set(),
2164 );
2165 expect(presented.map((entry) => entry.id)).toEqual([
2166 "prompt-1",
2167 `run-fold:${runId}`,
2168 "answer-1",
2169 "prompt-2",
2170 `run-fold:${run2}`,
2171 "answer-2",
2172 "closing-2",
2173 ]);
2174 });
2175
2176 it("keeps a wake run's work open when the user steers it", () => {
2177 const wakeRun = RunId.make("wake-run");
2178 const wakeItems: OrchestrationV2TurnItem[] = [
2179 user("prompt-1", runId, 1_000_001, 0),
2180 reasoning("reasoning-1", runId, 1_000_002, 1),
2181 assistant("answer-1", runId, 1_000_003, 3),
2182 {
2183 ...item("wake", wakeRun, 2_000_001, 30),
2184 type: "notification",
2185 source: { kind: "background_task" },
2186 outcome: "completed",
2187 summary: "Background task finished",
2188 },
2189 {
2190 ...item("wake-command", wakeRun, 2_000_002, 31),
2191 type: "command_execution",
2192 input: "vp test",
2193 output: "ok",
2194 exitCode: 0,
2195 },
2196 { ...user("steer", wakeRun, 2_000_003, 32), inputIntent: "steer" },
2197 ];
2198 const presented = deriveThreadFeedPresentation(
2199 buildThreadFeed(wakeItems.map((entry, index) => projected(entry, index))),
2200 { runId: wakeRun, status: "running", startedAt: at(30), completedAt: null },
2201 new Set(),
2202 new Set(),
2203 at(30),
2204 );
2205 expect(presented.map((entry) => entry.id)).toEqual([
2206 "prompt-1",
2207 `run-fold:${runId}`,
2208 "answer-1",
2209 "local:thread-1:wake",
2210 "work-toggle:work-group:local:thread-1:wake-command",
2211 "steer",
2212 "live-activity-row",
2213 ]);
2214 });
2215});
2216
2217describe("pending user input answers", () => {
2218 it("preserves exact editor text, including a deliberately cleared answer", () => {
2219 const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" };
2220 expect(
2221 buildPendingUserInputAnswers([question], {
2222 runtime: { customAnswer: question.initialAnswer },
2223 }),
2224 ).toEqual({ runtime: question.initialAnswer });
2225 expect(
2226 buildPendingUserInputAnswers([question], {
2227 runtime: { customAnswer: " Edited message\n\n" },
2228 }),
2229 ).toEqual({ runtime: " Edited message\n\n" });
2230 expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({
2231 runtime: "",
2232 });
2233 expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({
2234 runtime: " \n",
2235 });
2236 expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual(
2237 { customAnswer: "" },
2238 );
2239 });
2240
2241 it("replaces single-select options and toggles multi-select options", () => {
2242 expect(
2243 togglePendingUserInputOptionSelection(
2244 singleSelectQuestion,
2245 { selectedOptionValues: ["Go"] },
2246 "Node.js",
2247 ),
2248 ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
2249
2250 const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
2251 const ordersAndListings = togglePendingUserInputOptionSelection(
2252 multiSelectQuestion,
2253 orders,
2254 "Listings",
2255 );
2256 expect(ordersAndListings).toEqual({
2257 customAnswer: "",
2258 selectedOptionValues: ["Orders", "Listings"],
2259 });
2260 expect(
2261 togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
2262 ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
2263
2264 const paddedOrders = togglePendingUserInputOptionSelection(
2265 multiSelectQuestion,
2266 undefined,
2267 " Orders ",
2268 );
2269 expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
2270 expect(
2271 togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
2272 ).toEqual({ customAnswer: "" });
2273 });
2274
2275 it("builds array answers for multi-select questions", () => {
2276 expect(
2277 buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
2278 runtime: { selectedOptionValues: ["Go"] },
2279 scope: { selectedOptionValues: ["Orders", "Listings"] },
2280 }),
2281 ).toEqual({
2282 runtime: "Go",
2283 scope: ["Orders", "Listings"],
2284 });
2285 });
2286
2287 it("clears selected options while a custom answer is active", () => {
2288 expect(
2289 setPendingUserInputCustomAnswer(
2290 multiSelectQuestion,
2291 { selectedOptionValues: ["Orders", "Listings"] },
2292 "Orders first",
2293 ),
2294 ).toEqual({ customAnswer: "Orders first" });
2295 });
2296
2297 it("matches selected chips against normalized option labels", () => {
2298 expect(
2299 isPendingUserInputOptionSelected(
2300 multiSelectQuestion,
2301 { selectedOptionValues: ["Orders"] },
2302 " Orders ",
2303 ),
2304 ).toBe(true);
2305 expect(
2306 isPendingUserInputOptionSelected(
2307 multiSelectQuestion,
2308 { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
2309 " Orders ",
2310 ),
2311 ).toBe(false);
2312 });
2313});
2314
2315describe("provider question values", () => {
2316 const question = {
2317 ...singleSelectQuestion,
2318 allowCustomAnswer: false,
2319 options: [
2320 { label: "Same label", value: " exact first ", description: "First" },
2321 { label: "Same label", value: "second", description: "Second" },
2322 ],
2323 } as const;
2324
2325 it("submits raw option values and distinguishes duplicate labels", () => {
2326 const first = togglePendingUserInputOptionSelection(question, undefined, " exact first ");
2327 expect(isPendingUserInputOptionSelected(question, first, " exact first ")).toBe(true);
2328 expect(isPendingUserInputOptionSelected(question, first, "second")).toBe(false);
2329 expect(buildPendingUserInputAnswers([question], { runtime: first })).toEqual({
2330 runtime: " exact first ",
2331 });
2332 expect(togglePendingUserInputOptionSelection(question, first, "Same label")).toBe(first);
2333 });
2334
2335 it("rejects arbitrary text when the provider only accepts offered options", () => {
2336 expect(setPendingUserInputCustomAnswer(question, undefined, "Other")).toEqual({});
2337 expect(
2338 buildPendingUserInputAnswers([question], { runtime: { customAnswer: "Other" } }),
2339 ).toBeNull();
2340 expect(
2341 buildPendingUserInputAnswers([question], { runtime: { selectedOptionValues: ["unknown"] } }),
2342 ).toBeNull();
2343 expect(
2344 buildPendingUserInputAnswers([question], {
2345 runtime: { selectedOptionValues: ["second"], customAnswer: "stale draft" },
2346 }),
2347 ).toEqual({ runtime: "second" });
2348 });
2349});
2350
2351it("accepts ready attachment-only answers while preserving selected options", () => {
2352 const question = {
2353 id: "q",
2354 header: "Spec",
2355 question: "Provide a specification",
2356 options: [{ label: "Yes", description: "Approve" }],
2357 multiSelect: false,
2358 };
2359 expect(buildPendingUserInputAnswers([question], { q: { attachmentCount: 1 } })).toEqual({
2360 q: "",
2361 });
2362 expect(
2363 buildPendingUserInputAnswers([question], {
2364 q: { attachmentCount: 1, selectedOptionValues: ["Yes"] },
2365 }),
2366 ).toEqual({ q: "Yes" });
2367 expect(
2368 buildPendingUserInputAnswers([question], {
2369 q: { attachmentCount: 1, attachmentsBlocked: true },
2370 }),
2371 ).toBeNull();
2372 expect(
2373 buildPendingUserInputAnswers([{ ...question, allowCustomAnswer: false }], {
2374 q: { attachmentCount: 1 },
2375 }),
2376 ).toBeNull();
2377});
2378
2379it("makes attachment-only question answers expandable in the mobile feed", () => {
2380 const answer = {
2381 requestId: RuntimeRequestId.make("question-request"),
2382 answers: { q: "" },
2383 questionTextById: { q: "Attach the specification" },
2384 attachmentsByQuestionId: {
2385 q: [
2386 {
2387 type: "file" as const,
2388 id: "question-file",
2389 name: "spec.txt",
2390 mimeType: "text/plain",
2391 sizeBytes: 4,
2392 },
2393 ],
2394 },
2395 };
2396 const [group] = buildThreadFeed([
2397 projected(
2398 {
2399 ...base("answer-history", "2026-09-08T00:00:00.000Z", 0),
2400 type: "user_input_request",
2401 requestId: answer.requestId,
2402 questions: [],
2403 questionAnswer: answer,
2404 },
2405 0,
2406 ),
2407 ]);
2408 expect(group?.type).toBe("activity-group");
2409 if (group?.type !== "activity-group") return;
2410 expect(group.activities[0]).toMatchObject({
2411 canExpand: true,
2412 workEntry: { questionAnswer: answer },
2413 });
2414 expect(group.activities[0]?.getFullDetail()).toContain("spec.txt");
2415});
2416
2417it("renders automatic completion as a neutral activity while retaining its details", () => {
2418 const item = {
2419 ...base("notification", "2026-06-20T00:00:01.000Z", 0),
2420 type: "notification" as const,
2421 source: { kind: "monitor" as const },
2422 outcome: "updated" as const,
2423 summary: "Monitor reported an update",
2424 detail: "Build checks changed",
2425 };
2426 const feed = buildThreadFeed([
2427 projected(item, 0),
2428 projected(command(), 1),
2429 projected(assistantMessage(), 2),
2430 ]);
2431 expect(feed[0]?.type).toBe("activity-group");
2432 if (feed[0]?.type !== "activity-group") throw new Error("Expected notification activity");
2433 const activity = feed[0].activities[0]!;
2434 expect(activity.summary).toBe("Monitor reported an update");
2435 expect(activity.detail).toBeNull();
2436 expect(activity.status).toBeNull();
2437 expect(activity.getFullDetail()).toContain(item.detail);
2438 const presented = deriveThreadFeedPresentation(
2439 feed,
2440 {
2441 runId,
2442 status: "completed",
2443 startedAt: "2026-06-20T00:00:01.000Z",
2444 completedAt: "2026-06-20T00:00:03.000Z",
2445 },
2446 new Set(),
2447 );
2448 expect(
2449 presented.some(
2450 (entry) =>
2451 entry.type === "activity-group" &&
2452 entry.activities.some((activity) => activity.summary === "Monitor reported an update"),
2453 ),
2454 ).toBe(true);
2455 expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message");
2456});
2457
2458it("uses a compact reasoning preview and a short expanded heading", () => {
2459 const entry = {
2460 id: "thought",
2461 label: "Thinking",
2462 createdAt: "2026-09-17T12:00:00Z",
2463 itemType: "reasoning" as const,
2464 tone: "thinking" as const,
2465 detail: "Check **ordering**.\nThen run the test.",
2466 toolLifecycleStatus: "inProgress" as const,
2467 };
2468 expect(workEntryRowLabel(entry)).toBe("Check **ordering**. Then run the test.");
2469 expect(workEntryRowLabel(entry, true)).toBe("Thinking");
2470 expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought");
2471});
2472
2473it("keeps search output in expanded details rather than the compact label", () => {
2474 const entry = {
2475 id: "search",
2476 label: "Grep",
2477 toolTitle: "Grep",
2478 createdAt: "2026-09-17T12:00:00Z",
2479 itemType: "dynamic_tool" as const,
2480 tone: "tool" as const,
2481 detail: "---\nfile body",
2482 toolData: {},
2483 };
2484 expect(workEntryRowLabel(entry)).toBe("Grep");
2485 expect(workEntryRowLabel(entry, true)).toBe("---\nfile body");
2486});
2487
2488it.each(["First paragraph.\n\nSecond paragraph.", ""])(
2489 "previews live reasoning text %j",
2490 (text) => {
2491 const thought: OrchestrationV2TurnItem = {
2492 ...base("live-thought", "2026-06-20T00:00:02.000Z", 1),
2493 type: "reasoning",
2494 status: "running",
2495 completedAt: null,
2496 streaming: true,
2497 text,
2498 };
2499 const feed = buildThreadFeed([projected(userMessage(), 0), projected(thought, 1)]);
2500 const rows = deriveThreadFeedPresentation(
2501 feed,
2502 { runId, status: "running", startedAt: "2026-06-20T00:00:01.000Z", completedAt: null },
2503 new Set(),
2504 new Set(),
2505 "2026-06-20T00:00:01.000Z",
2506 );
2507 if (text) {
2508 expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({
2509 summary: "Thinking",
2510 thought: "First paragraph.",
2511 live: true,
2512 });
2513 } else {
2514 expect(
2515 rows.some(
2516 (row) =>
2517 row.type === "thinking" || (row.type === "work-toggle" && row.summary === "Thinking"),
2518 ),
2519 ).toBe(true);
2520 }
2521 },
2522);
2523
2524it("keeps the latest thought under the live tool status", () => {
2525 const at = "2026-06-20T00:00:02.000Z";
2526 const thought: OrchestrationV2TurnItem = {
2527 ...base("found-thought", at, 1),
2528 type: "reasoning",
2529 status: "completed",
2530 streaming: false,
2531 text: "Found the cause: no commits yet. Checking the UI next.",
2532 };
2533 const tool = { ...command(at), status: "running" as const, completedAt: null };
2534 const rows = deriveThreadFeedPresentation(
2535 buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]),
2536 { runId, status: "running", startedAt: at, completedAt: null },
2537 new Set(),
2538 new Set(),
2539 at,
2540 );
2541 expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({
2542 thought: "Found the cause: no commits yet.",
2543 live: true,
2544 });
2545 expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({
2546 summary: "Thinking",
2547 });
2548});
2549
2550it("stops stranded thinking after a steer and follows the next thought or tool", () => {
2551 const at = "2026-06-20T00:00:02.000Z";
2552 const thought = (id: string): OrchestrationV2TurnItem => ({
2553 ...base(id, at, 1),
2554 type: "reasoning",
2555 status: "running",
2556 completedAt: null,
2557 streaming: true,
2558 text: id,
2559 });
2560 const first = thought("first-thought");
2561 const next = thought("next-thought");
2562 const steer = { ...userMessage(at), inputIntent: "steer" as const };
2563 const tool = { ...command(at), status: "running" as const, completedAt: null };
2564 const rows = (items: ReadonlyArray<OrchestrationV2TurnItem>, expanded = new Set<string>()) =>
2565 deriveThreadFeedPresentation(
2566 buildThreadFeed(items.map((item, position) => projected(item, position))),
2567 { runId, status: "running", startedAt: at, completedAt: null },
2568 new Set(),
2569 expanded,
2570 at,
2571 );
2572 expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({
2573 summary: "Thinking",
2574 thought: "first-thought",
2575 live: true,
2576 shimmer: true,
2577 });
2578 const afterSteer = rows([first, steer]);
2579 expect(afterSteer.find((row) => row.type === "work-toggle")).toMatchObject({
2580 live: false,
2581 shimmer: false,
2582 });
2583 expect(afterSteer.at(-1)?.type).toBe("thinking");
2584 const header = afterSteer.find((row) => row.type === "work-toggle");
2585 if (header?.type !== "work-toggle") throw new Error("Expected thought toggle");
2586 const expanded = rows([first, steer], new Set([header.groupId]));
2587 expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({ summary: "Thought" });
2588 expect(expanded.find((row) => row.type === "activity-group")).toMatchObject({
2589 activities: [{ lifecycleStatus: "completed", workEntry: { toolLifecycleStatus: "completed" } }],
2590 });
2591 for (const items of [
2592 [first, steer, next],
2593 [first, next],
2594 [first, steer, next, tool],
2595 ]) {
2596 const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer);
2597 expect(live).toHaveLength(1);
2598 expect(live[0]).toMatchObject(
2599 items.at(-1)!.type === "reasoning"
2600 ? { summary: "Thinking", thought: "next-thought" }
2601 : { summary: "Running vp" },
2602 );
2603 }
2604 expect(first.status).toBe("running");
2605});
2606
2607it("previews a settled thought in its collapsed header and labels its expanded header", () => {
2608 const thought: OrchestrationV2TurnItem = {
2609 ...base("thought-preview", "2026-06-20T00:00:02.000Z", 1),
2610 type: "reasoning",
2611 streaming: false,
2612 text: "First paragraph.\n\nSecond paragraph.",
2613 };
2614 const feed = buildThreadFeed([
2615 projected(userMessage(), 0),
2616 projected(thought, 1),
2617 projected(assistantMessage(), 2),
2618 ]);
2619 const run = {
2620 runId,
2621 status: "completed" as const,
2622 startedAt: "2026-06-20T00:00:01.000Z",
2623 completedAt: "2026-06-20T00:00:03.000Z",
2624 };
2625 const collapsed = deriveThreadFeedPresentation(feed, run, new Set([runId]));
2626 const header = collapsed.find((row) => row.type === "work-toggle");
2627 expect(header).toMatchObject({ summary: "First paragraph. Second paragraph." });
2628 if (header?.type !== "work-toggle") throw new Error("Expected thought toggle");
2629 const expanded = deriveThreadFeedPresentation(
2630 feed,
2631 run,
2632 new Set([runId]),
2633 new Set([header.groupId]),
2634 );
2635 expect(expanded.find((row) => row.type === "work-toggle")).toMatchObject({
2636 summary: "Thought",
2637 continuesWorkLog: true,
2638 });
2639 const detail = expanded.find((row) => row.type === "activity-group");
2640 expect(detail?.continuesWorkLog).toBeUndefined();
2641 if (detail?.type !== "activity-group") throw new Error("Expected full thought");
2642 expect(detail.activities[0]?.detail).toBe(thought.text);
2643});
2644
2645it.each(["provider_error", "usage_limit"] as const)(
2646 "keeps a historical %s failure and preceding work visible without disclosures",
2647 (failureClass) => {
2648 const at = "2026-06-20T00:00:03.000Z";
2649 const error: OrchestrationV2TurnItem = {
2650 ...base("failure", at, 2),
2651 type: "error",
2652 status: "failed",
2653 failure: {
2654 class: failureClass,
2655 message: "The provider stopped this turn.\nRetry later.",
2656 code: null,
2657 retryable: true,
2658 },
2659 };
2660 const command: OrchestrationV2TurnItem = {
2661 ...base("command", "2026-06-20T00:00:02.000Z", 1),
2662 type: "command_execution",
2663 input: "pwd",
2664 output: "",
2665 exitCode: 0,
2666 };
2667 const sourceFeed = buildThreadFeed([
2668 projected(userMessage(), 0),
2669 projected(command, 1),
2670 projected(error, 2),
2671 ]);
2672 const feed = deriveThreadFeedPresentation(
2673 sourceFeed,
2674 { runId: RunId.make("newer-run"), status: "completed", startedAt: at, completedAt: at },
2675 new Set(),
2676 );
2677 const whileWorking = deriveThreadFeedPresentation(
2678 sourceFeed,
2679 { runId: RunId.make("newer-run"), status: "running", startedAt: at, completedAt: null },
2680 new Set(),
2681 );
2682 for (const entry of feed) {
2683 expect(whileWorking.find((row) => row.id === entry.id)).toBe(entry);
2684 }
2685 expect(feed.some((entry) => entry.type === "run-fold" || entry.type === "work-toggle")).toBe(
2686 false,
2687 );
2688 const activities = feed.flatMap((entry) =>
2689 entry.type === "activity-group" ? entry.activities : [],
2690 );
2691 expect(activities.map((activity) => activity.projectedItem.item.id)).toEqual([
2692 "command",
2693 "failure",
2694 ]);
2695 expect(activities.at(-1)).toMatchObject({
2696 detail: error.failure.message,
2697 createdAt: at,
2698 canExpand: false,
2699 prominent: true,
2700 });
2701 },
2702);
2703
2704describe("html renders", () => {
2705 const page = { attachmentId: "attachment-page", title: "Revenue", height: 320 };
2706 const renderCall = (
2707 overrides: Partial<Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>> = {},
2708 ): OrchestrationV2TurnItem => ({
2709 ...base("item-render", "2026-06-20T00:00:02.500Z", 2),
2710 type: "dynamic_tool",
2711 toolName: "mcp__t3-code__html_render",
2712 input: { title: page.title },
2713 output: { htmlRender: page },
2714 ...overrides,
2715 });
2716 const laterCommand = {
2717 ...command("2026-06-20T00:00:02.800Z"),
2718 id: TurnItemId.make("item-command-later"),
2719 ordinal: 3,
2720 };
2721 const feed = () =>
2722 buildThreadFeed([
2723 projected(userMessage(), 0),
2724 projected(command(), 1),
2725 projected(renderCall(), 2),
2726 projected(laterCommand, 3),
2727 projected(assistantMessage("2026-06-20T00:00:04.000Z"), 4),
2728 ]);
2729 const latestRun = {
2730 runId,
2731 status: "completed" as const,
2732 startedAt: "2026-06-20T00:00:01.000Z",
2733 completedAt: "2026-06-20T00:00:04.000Z",
2734 };
2735
2736 it("shows a completed render in place, outside the work log", () => {
2737 const expanded = deriveThreadFeedPresentation(feed(), latestRun, new Set([runId]));
2738 expect(expanded.map((entry) => entry.type)).toEqual([
2739 "message",
2740 "run-fold",
2741 "work-toggle",
2742 "html-render",
2743 "work-toggle",
2744 "message",
2745 ]);
2746 expect(expanded[3]).toMatchObject({ type: "html-render", render: page, runId });
2747 expect(expanded[2]?.continuesWorkLog).toBeUndefined();
2748 });
2749
2750 it("keeps a render visible and in order when its run folds", () => {
2751 const collapsed = deriveThreadFeedPresentation(feed(), latestRun, new Set());
2752 expect(collapsed.map((entry) => entry.type)).toEqual([
2753 "message",
2754 "run-fold",
2755 "html-render",
2756 "message",
2757 ]);
2758 });
2759
2760 it("leaves running, failed and errored renders in the work log", () => {
2761 for (const call of [
2762 renderCall({ status: "running", output: null }),
2763 renderCall({ status: "failed" }),
2764 renderCall({ output: { isError: true, htmlRender: page } }),
2765 ]) {
2766 const entries = buildThreadFeed([projected(call, 0)]);
2767 expect(entries.map((entry) => entry.type)).toEqual(["activity-group"]);
2768 }
2769 });
2770});
2771
2772describe("MCP apps", () => {
2773 const app = {
2774 attachmentId: "attachment-app",
2775 server: "weather",
2776 tool: "get_weather",
2777 resourceUri: "ui://weather/dashboard",
2778 };
2779 const appCall = (status: OrchestrationV2TurnItem["status"]): OrchestrationV2TurnItem => ({
2780 ...base("item-app", "2026-06-20T00:00:02.500Z", 2),
2781 status,
2782 type: "dynamic_tool",
2783 toolName: "weather.get_weather",
2784 input: { city: "Oslo" },
2785 output: { t3McpApp: app, result: { content: [] } },
2786 });
2787 const feed = (status: OrchestrationV2TurnItem["status"]) =>
2788 buildThreadFeed([
2789 projected(userMessage(), 0),
2790 projected(appCall(status), 1),
2791 projected(assistantMessage("2026-06-20T00:00:04.000Z"), 2),
2792 ]);
2793
2794 it("hosts a completed app in place of its tool row, owned by its source item", () => {
2795 const entry = feed("completed").find((candidate) => candidate.type === "mcp-app");
2796 expect(entry).toMatchObject({ type: "mcp-app", app, itemId: "item-app", runId });
2797 });
2798
2799 it("keeps a running app call an ordinary work row", () => {
2800 expect(feed("running").some((candidate) => candidate.type === "mcp-app")).toBe(false);
2801 });
2802});