1import { act, cloneElement, type ReactElement, type ReactNode } from "react";
2import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer";
3import {
4 EnvironmentId,
5 ThreadId,
6 type ModelSelection,
7 type ProviderOptionDescriptor,
8 type OrchestrationV2ContextTransfer,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import { afterEach, expect, it, vi } from "vite-plus/test";
12
13const state = vi.hoisted(() => ({
14 projection: null as unknown,
15 navigate: vi.fn(),
16 shells: [] as unknown[],
17 projects: [] as unknown[],
18 configs: new Map<string, unknown>(),
19 showTooltips: false,
20 command: vi.fn().mockResolvedValue({ _tag: "Success" }),
21 workflowCommand: vi.fn().mockResolvedValue({ _tag: "Success" }),
22 toast: vi.fn(),
23}));
24
25vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate }));
26vi.mock("../../state/entities", () => ({
27 useThreadProjection: () => ({ projection: state.projection }),
28 useThreadShells: () => state.shells,
29 useProjects: () => state.projects,
30 useServerConfigs: () => state.configs,
31}));
32vi.mock("../../lib/archivedThreadsState", () => ({
33 useArchivedThreadSnapshots: () => ({ snapshots: [] }),
34}));
35vi.mock("../../state/use-atom-command", () => ({
36 useAtomCommand: (command: { label: string }) =>
37 command.label === "environment-data:orchestration:stop-workflow"
38 ? state.workflowCommand
39 : state.command,
40}));
41vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } }));
42vi.mock("../../state/orchestration", async () => {
43 const { Atom } = await import("effect/reactivity");
44 const granted = Atom.make(true);
45 return {
46 orchestrationEnvironment: {
47 stopWorkflow: {
48 label: "environment-data:orchestration:stop-workflow",
49 permissionAtom: () => granted,
50 },
51 },
52 };
53});
54vi.mock("../ui/tooltip", () => ({
55 Tooltip: ({ children }: { children: ReactNode }) => children,
56 TooltipTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
57 cloneElement(render, {}, children),
58 TooltipPopup: ({ children }: { children: ReactNode }) => (state.showTooltips ? children : null),
59}));
60
61import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl";
62
63let renderer: ReactTestRenderer;
64
65afterEach(async () => {
66 await act(async () => renderer?.unmount());
67 vi.unstubAllGlobals();
68 state.shells = [];
69 state.projects = [];
70 state.configs.clear();
71 state.showTooltips = false;
72 state.navigate.mockClear();
73 state.command.mockClear();
74 state.workflowCommand.mockReset().mockResolvedValue({ _tag: "Success" });
75 state.toast.mockClear();
76 state.projection = null;
77});
78
79it("stops the entire native workflow through its parent without changing state before acknowledgement", async () => {
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 const coordinator = {
82 id: "workflow",
83 origin: "provider_native",
84 driver: "claudeAgent",
85 providerInstanceId: "claude-work",
86 childThreadId: "workflow-chat",
87 title: "Checkout review",
88 status: "running",
89 startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
90 completedAt: null,
91 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
92 workflow: {
93 name: "Checkout review",
94 phases: [{ index: 0, title: "Inspect" }],
95 agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }],
96 },
97 };
98 state.projection = {
99 thread: { id: "parent", lineage: { relationshipToParent: null } },
100 runs: [],
101 providerThreads: [],
102 providerSessions: [],
103 contextTransfers: [],
104 subagents: [
105 coordinator,
106 {
107 ...coordinator,
108 id: "worker",
109 childThreadId: "worker-chat",
110 origin: "app_owned",
111 title: "Worker",
112 workflow: undefined,
113 },
114 ],
115 };
116 state.shells = [
117 {
118 environmentId: "remote",
119 source: {
120 id: "workflow-chat",
121 title: "Coordinator chat",
122 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
123 activityRunStatus: "waiting",
124 },
125 },
126 ];
127 await act(async () => {
128 renderer = create(
129 <ThreadRelationshipsPanel
130 environmentId={EnvironmentId.make("remote")}
131 threadId={ThreadId.make("parent")}
132 />,
133 );
134 });
135 await act(async () =>
136 renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(),
137 );
138 const stopButton = () =>
139 renderer.root.findByProps({ "aria-label": "Stop workflow Checkout review" });
140 expect(
141 renderer.root.findAll(
142 (node) => node.type === "button" && String(node.props["aria-label"]).startsWith("Stop "),
143 ),
144 ).toHaveLength(2);
145 let acknowledge!: (result: { _tag: string }) => void;
146 state.workflowCommand.mockImplementationOnce(
147 () =>
148 new Promise((resolve) => {
149 acknowledge = resolve;
150 }),
151 );
152 await act(async () => {
153 stopButton().props.onClick();
154 });
155 expect(state.workflowCommand).toHaveBeenCalledExactlyOnceWith({
156 environmentId: "remote",
157 input: { threadId: "parent", subagentId: "workflow" },
158 });
159 expect(state.command).not.toHaveBeenCalled();
160 expect(state.navigate).not.toHaveBeenCalled();
161 expect(stopButton().props.disabled).toBe(true);
162 expect(renderer.root.findByProps({ "aria-label": "Stop subagent Worker" }).props.disabled).toBe(
163 true,
164 );
165 expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined();
166 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]);
167 await act(async () => {
168 acknowledge({ _tag: "Success" });
169 });
170 expect(stopButton().props.disabled).toBe(false);
171 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 2 running"]);
172 state.workflowCommand.mockResolvedValueOnce({ _tag: "Failure" });
173 await act(async () => {
174 stopButton().props.onClick();
175 });
176 expect(state.toast).toHaveBeenCalledExactlyOnceWith({
177 type: "error",
178 title: "Could not stop workflow",
179 });
180 expect(stopButton().props.disabled).toBe(false);
181 expect(state.command).not.toHaveBeenCalled();
182 expect(state.navigate).not.toHaveBeenCalled();
183});
184
185it.each([
186 ["claudeAgent", "provider_native", "completed"],
187 ["claudeAgent", "provider_native", "failed"],
188 ["claudeAgent", "provider_native", "interrupted"],
189 ["claudeAgent", "provider_native", "waiting"],
190 ["codex", "provider_native", "running"],
191 ["claudeAgent", "app_owned", "running"],
192])("does not offer workflow stop for %s %s %s", async (driver, origin, status) => {
193 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
194 state.projection = {
195 thread: { id: "parent", lineage: { relationshipToParent: null } },
196 runs: [],
197 providerThreads: [],
198 providerSessions: [],
199 contextTransfers: [],
200 subagents: [
201 {
202 id: "workflow",
203 driver,
204 origin,
205 status,
206 providerInstanceId: "claude-work",
207 childThreadId: "workflow-chat",
208 title: "Checkout review",
209 startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
210 completedAt: null,
211 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
212 workflow: { name: "Checkout review", phases: [], agents: [] },
213 },
214 ],
215 };
216 await act(async () => {
217 renderer = create(
218 <ThreadRelationshipsPanel
219 environmentId={EnvironmentId.make("remote")}
220 threadId={ThreadId.make("parent")}
221 />,
222 );
223 });
224 const previousAgents = renderer.root.findAll(
225 (node) =>
226 node.type === "button" && node.props["aria-expanded"] === false && !node.props["aria-label"],
227 );
228 if (previousAgents.length > 0) await act(async () => previousAgents[0]!.props.onClick());
229 expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined();
230 expect(
231 renderer.root.findAllByProps({ "aria-label": "Stop workflow Checkout review" }),
232 ).toHaveLength(0);
233 expect(state.workflowCommand).not.toHaveBeenCalled();
234});
235
236it("opens the correct chat for every workflow phase and unphased member", async () => {
237 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
238 state.showTooltips = true;
239 state.configs.set("remote", {
240 providers: [
241 {
242 instanceId: "claude-personal",
243 driver: "claudeAgent",
244 displayName: "Personal account",
245 models: [],
246 },
247 {
248 instanceId: "claude-work",
249 driver: "claudeAgent",
250 displayName: "Work account",
251 models: [{ slug: "claude-sonnet-4-6", name: "Sonnet" }],
252 },
253 ],
254 });
255 const phases = ["Inspect", "Improve", "Verify"].map((title, index) => ({ index, title }));
256 const agents = Array.from({ length: 7 }, (_, index) => ({
257 index,
258 label: `Member ${index}`,
259 state:
260 index === 2 ? "cancelled" : index === 4 ? "failed" : index === 5 ? "running" : "completed",
261 ...(index < 6 ? { phaseIndex: Math.floor(index / 2) } : {}),
262 childThreadId: `member-chat-${index}`,
263 model: "claude-sonnet-4-6",
264 result: `Result ${index}`,
265 totalTokens: 1200,
266 // Staggered starts, so a phase spans longer than either member's own run.
267 startedAt: 1_700_000_000_000 + index * 1000,
268 durationMs: 5000,
269 }));
270 const project = (members: typeof agents) => ({
271 thread: { id: "parent", lineage: { relationshipToParent: null } },
272 runs: [],
273 providerThreads: [],
274 providerSessions: [],
275 contextTransfers: [],
276 subagents: [
277 {
278 id: "workflow",
279 driver: "claudeAgent",
280 providerInstanceId: "claude-work",
281 childThreadId: "workflow-chat",
282 title: "Checkout review",
283 status: "running",
284 startedAt: null,
285 completedAt: null,
286 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
287 workflow: { name: "Checkout review", phases, agents: members },
288 },
289 ],
290 });
291 state.projection = project(agents);
292 // Matching IDs from another environment cannot make these chats available.
293 const memberShells = agents.map((agent) => ({
294 id: agent.childThreadId,
295 title: agent.label,
296 lineage: { parentThreadId: "workflow-chat", relationshipToParent: "subagent" },
297 status: "completed",
298 activityRunStatus: null,
299 }));
300 state.shells = memberShells.map((source) => ({ environmentId: "other", source }));
301 const panel = (
302 <ThreadRelationshipsPanel
303 environmentId={EnvironmentId.make("remote")}
304 threadId={ThreadId.make("parent")}
305 />
306 );
307 await act(async () => {
308 renderer = create(panel);
309 });
310 await act(async () =>
311 renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(),
312 );
313 const memberButtons = () =>
314 renderer.root.findAll(
315 (node) =>
316 node.type === "button" && String(node.props["aria-label"]).startsWith("Open Member"),
317 );
318 const phaseButtons = () =>
319 renderer.root.findAll(
320 (node) =>
321 node.type === "button" &&
322 typeof node.props["aria-expanded"] === "boolean" &&
323 String(node.props["aria-label"]).includes(" phase, "),
324 );
325 // Only the running phase starts open, beside the unphased member.
326 expect(memberButtons()).toHaveLength(3);
327 expect(phaseButtons()).toHaveLength(3);
328 expect(renderer.root.findByProps({ "aria-label": "Expand Inspect phase, done" })).toBeDefined();
329 expect(
330 renderer.root.findByProps({ "aria-label": "Expand Improve phase, stopped" }),
331 ).toBeDefined();
332 expect(
333 renderer.root.findByProps({ "aria-label": "Collapse Verify phase, running" }),
334 ).toBeDefined();
335 for (const phase of phaseButtons().slice(0, 2)) await act(async () => phase.props.onClick());
336 expect(memberButtons()).toHaveLength(7);
337 for (const button of memberButtons()) {
338 expect(button.props.disabled).toBe(true);
339 await act(async () => button.props.onClick());
340 }
341 expect(state.navigate).not.toHaveBeenCalled();
342 // The coordinator roster arrives before the member thread shells.
343 state.shells = memberShells.map((source) => ({ environmentId: "remote", source }));
344 await act(async () => renderer.update(cloneElement(panel)));
345 for (const button of memberButtons()) expect(button.props.disabled).toBe(false);
346 for (const agent of agents) {
347 await act(async () =>
348 renderer.root.findByProps({ "aria-label": `Open ${agent.label} chat` }).props.onClick(),
349 );
350 expect(state.navigate).toHaveBeenLastCalledWith({
351 to: "/$environmentId/$threadId",
352 params: { environmentId: "remote", threadId: agent.childThreadId },
353 });
354 }
355 const rendered = JSON.stringify(renderer.toJSON());
356 expect(rendered.match(/Sonnet · Work account/g)).toHaveLength(7);
357 expect(rendered).not.toContain("Personal account");
358 for (const phase of phases) expect(rendered).toContain(phase.title);
359 // The closed row leaves phase progress to the tree.
360 expect(rendered).not.toContain(" phases");
361 expect(rendered).toContain('"1","/","2"," ","agents"');
362 expect(rendered).toContain("Running");
363 // The phase holding the running member reports itself as the active one.
364 expect(rendered).toContain("running");
365 expect(rendered).toContain("done");
366 expect(rendered).toContain("stopped");
367 // Members 0 and 1 start a second apart and run 5s each, so Inspect took 6s.
368 expect(rendered).toContain('"6s"');
369 for (const phase of phaseButtons()) {
370 const label = String(phase.props["aria-label"]).slice("Collapse ".length);
371 await act(async () => phase.props.onClick());
372 expect(memberButtons()).toHaveLength(5);
373 await act(async () =>
374 renderer.root.findByProps({ "aria-label": `Expand ${label}` }).props.onClick(),
375 );
376 expect(memberButtons()).toHaveLength(7);
377 }
378 // A phase the user closed stays closed once it starts running, and stays
379 // closed after it settles.
380 await act(async () => phaseButtons()[0]!.props.onClick());
381 expect(memberButtons()).toHaveLength(5);
382 const restarted = agents.map((agent) =>
383 agent.index === 0 ? { ...agent, state: "running" } : agent,
384 );
385 state.projection = project(restarted);
386 await act(async () => renderer.update(cloneElement(panel)));
387 expect(memberButtons()).toHaveLength(5);
388 state.projection = project(agents);
389 await act(async () => renderer.update(cloneElement(panel)));
390 expect(memberButtons()).toHaveLength(5);
391 await act(async () =>
392 renderer.root.findByProps({ "aria-label": "Collapse Checkout review" }).props.onClick(),
393 );
394 expect(memberButtons()).toHaveLength(0);
395 expect(state.navigate).toHaveBeenCalledTimes(7);
396});
397
398it("groups and counts a workflow by its run status instead of its coordinator chat", async () => {
399 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
400 state.showTooltips = true;
401 const coordinator = {
402 id: "workflow",
403 driver: "claudeAgent",
404 providerInstanceId: "claudeAgent",
405 childThreadId: "workflow-chat",
406 title: "Checkout review",
407 status: "running",
408 startedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
409 progress: "Inspecting the checkout workflow",
410 result: "Workflow checks complete",
411 completedAt: null,
412 updatedAt: DateTime.makeUnsafe("2026-09-21T12:00:00Z"),
413 workflow: {
414 name: "Checkout review",
415 phases: [{ index: 0, title: "Inspect" }],
416 agents: [{ index: 0, label: "Checker", state: "running", phaseIndex: 0 }],
417 },
418 };
419 const projection = {
420 thread: { id: "parent", lineage: { relationshipToParent: null } },
421 runs: [],
422 providerThreads: [],
423 providerSessions: [],
424 contextTransfers: [],
425 subagents: [coordinator],
426 };
427 const child = {
428 id: "workflow-chat",
429 title: "Coordinator chat",
430 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
431 status: "completed",
432 activityRunStatus: null,
433 };
434 state.projection = projection;
435 state.shells = [{ environmentId: "test", source: child }];
436 const panel = (
437 <ThreadRelationshipsPanel
438 environmentId={EnvironmentId.make("test")}
439 threadId={ThreadId.make("parent")}
440 />
441 );
442 await act(async () => {
443 renderer = create(panel);
444 });
445 const text = () =>
446 renderer.root
447 .findAll((node) => typeof node.type === "string")
448 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
449 .join("");
450 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]);
451 await act(async () =>
452 renderer.root.findByProps({ "aria-label": "Expand Checkout review" }).props.onClick(),
453 );
454 expect(renderer.root.findByProps({ "aria-label": "Open Checker chat" })).toBeDefined();
455 state.shells = [{ environmentId: "test", source: { ...child, activityRunStatus: "waiting" } }];
456 await act(async () => renderer.update(cloneElement(panel)));
457 expect(renderer.root.findByType("h3").children).toEqual(["Lineage · 1 running"]);
458 expect(text()).toContain("Inspecting the checkout workflow");
459 state.projection = {
460 ...projection,
461 subagents: [
462 {
463 ...coordinator,
464 status: "completed",
465 completedAt: DateTime.makeUnsafe("2026-09-21T12:10:00Z"),
466 },
467 ],
468 };
469 await act(async () => renderer.update(cloneElement(panel)));
470 expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]);
471 expect(text()).toContain("Previous agents");
472 expect(renderer.root.findAllByProps({ "aria-label": "Open Checker chat" })).toHaveLength(0);
473 await act(async () =>
474 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
475 );
476 expect(renderer.root.findByProps({ "aria-label": "Expand Checkout review" })).toBeDefined();
477 expect(text()).toContain("Workflow checks complete");
478 expect(text()).toContain("10m");
479});
480
481it.each([
482 { driver: "codex", origin: "app_owned", runId: null, canStop: true },
483 { driver: "claudeAgent", origin: "app_owned", runId: null, canStop: true },
484 { driver: "claudeAgent", origin: "provider_native", runId: "run-1", canStop: true },
485 { driver: "claudeAgent", origin: "provider_native", runId: null, canStop: false },
486])(
487 "offers individual Stop for supported $origin $driver subagents with run $runId",
488 async ({ driver, origin, runId, canStop }) => {
489 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
490 const parent = {
491 id: "parent",
492 lineage: { relationshipToParent: null },
493 activeProviderThreadId: null,
494 };
495 const child = {
496 id: "child",
497 title: "Worker",
498 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
499 };
500 const agent = {
501 id: "agent",
502 childThreadId: "child",
503 origin,
504 threadId: "parent",
505 runId,
506 nativeTaskRef: { driver, nativeId: "claude-task", strength: "strong" },
507 driver,
508 providerInstanceId: "codex",
509 title: "Worker",
510 prompt: "Check the change",
511 model: "gpt-5.4",
512 status: "running",
513 progress: null,
514 result: null,
515 startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
516 completedAt: null,
517 updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
518 };
519 state.shells = [{ environmentId: "test", source: child }];
520 const projection = {
521 thread: parent,
522 runs: [],
523 providerThreads: [],
524 providerSessions: [],
525 contextTransfers: [],
526 subagents: [agent],
527 };
528 state.projection = projection;
529 const panel = (
530 <ThreadRelationshipsPanel
531 environmentId={EnvironmentId.make("test")}
532 threadId={ThreadId.make("parent")}
533 />
534 );
535 await act(async () => {
536 renderer = create(panel);
537 });
538 if (!canStop) {
539 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
540 0,
541 );
542 return;
543 }
544 const stopButton = () => renderer.root.findByProps({ "aria-label": "Stop subagent Worker" });
545 await act(async () => stopButton().props.onClick());
546 expect(state.command).toHaveBeenCalledWith({
547 environmentId: "test",
548 input:
549 origin === "provider_native"
550 ? { threadId: "parent", subagentId: "agent" }
551 : { threadId: "child" },
552 });
553 expect(state.navigate).not.toHaveBeenCalled();
554
555 for (const status of ["starting", "running", "waiting"] as const) {
556 state.command.mockClear();
557 state.shells = [
558 {
559 environmentId: "test",
560 source: {
561 ...child,
562 activityRunStatus: status,
563 activityRunStartedAt: DateTime.makeUnsafe("2026-09-16T12:05:00Z"),
564 },
565 },
566 ];
567 state.projection = {
568 ...projection,
569 subagents: [{ ...agent, origin: "provider_native", status: "completed" }],
570 };
571 await act(async () => renderer.update(cloneElement(panel)));
572 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
573 0,
574 );
575 state.projection = { ...projection, subagents: [{ ...agent, status: "completed" }] };
576 await act(async () => renderer.update(cloneElement(panel)));
577 if (origin === "provider_native") {
578 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
579 0,
580 );
581 continue;
582 }
583 await act(async () => stopButton().props.onClick());
584 expect(state.command).toHaveBeenCalledTimes(1);
585 expect(state.command).toHaveBeenLastCalledWith({
586 environmentId: "test",
587 input: { threadId: "child" },
588 });
589 }
590 state.shells = [{ environmentId: "test", source: child }];
591 for (const status of ["completed", "failed", "interrupted"]) {
592 state.projection = { ...projection, subagents: [{ ...agent, status }] };
593 await act(async () => renderer.update(cloneElement(panel)));
594 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(
595 0,
596 );
597 }
598 state.projection = { ...projection, subagents: [{ ...agent, startedAt: null }] };
599 await act(async () => renderer.update(cloneElement(panel)));
600 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);
601 state.projection = {
602 ...projection,
603 subagents: [{ ...agent, origin: "provider_native", driver: "codex" }],
604 };
605 await act(async () => renderer.update(cloneElement(panel)));
606 expect(renderer.root.findAllByProps({ "aria-label": "Stop subagent Worker" })).toHaveLength(0);
607 },
608);
609
610it("shows the matching child agent details and refreshes them when the agent settles", async () => {
611 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
612 const agent = {
613 id: "agent-1",
614 driver: "codex",
615 providerInstanceId: "codex",
616 childThreadId: "child-1",
617 title: "Checker",
618 prompt: "Check the change",
619 model: "gpt-5.4",
620 status: "running",
621 progress: "Running checks",
622 result: null,
623 startedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
624 completedAt: null,
625 updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
626 };
627 const projection = {
628 thread: {
629 id: "parent",
630 lineage: { relationshipToParent: null },
631 activeProviderThreadId: null,
632 },
633 runs: [],
634 providerThreads: [],
635 providerSessions: [],
636 contextTransfers: [],
637 subagents: [
638 { ...agent, id: "unlinked", childThreadId: null, title: "Unlinked agent" },
639 agent,
640 { ...agent, id: "agent-2", childThreadId: "child-2", title: "Worker", model: "gpt-5.3" },
641 ],
642 };
643 state.projection = projection;
644 const panel = (
645 <ThreadRelationshipsPanel
646 environmentId={EnvironmentId.make("test")}
647 threadId={ThreadId.make("parent")}
648 />
649 );
650 await act(async () => {
651 renderer = create(panel);
652 });
653 const text = () =>
654 renderer.root
655 .findAll((node) => typeof node.type === "string")
656 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
657 .join(" ")
658 .replace(/\s+/g, " ");
659 expect(text()).toContain("Checker");
660 expect(text()).toContain("Lineage · 3 running");
661 expect(text()).toContain("running");
662 expect(text()).not.toContain("gpt-5.4");
663 expect(text()).not.toContain("gpt-5.3");
664 expect(text()).not.toContain("tok");
665 expect(text()).not.toContain("Unlinked agent");
666 expect(text()).not.toContain("Active agents");
667 expect(renderer.root.findAllByProps({ type: "button", "aria-expanded": true })).toHaveLength(0);
668
669 state.projection = {
670 ...projection,
671 subagents: [
672 {
673 ...agent,
674 status: "completed",
675 progress: undefined,
676 result: "All checks passed",
677 completedAt: DateTime.makeUnsafe("2026-09-16T12:02:15Z"),
678 },
679 ],
680 };
681 await act(async () => renderer.update(cloneElement(panel)));
682 expect(renderer.root.findByType("h3").children).toEqual(["Lineage"]);
683 expect(text()).toContain("Previous agents (1)");
684 expect(text()).not.toContain("Checker");
685 await act(async () =>
686 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
687 );
688 expect(text()).toContain("Checker");
689 // A started agent's row shows only its compact time; the icon carries the status.
690 expect(text()).toContain("Checker 2m");
691 expect(text()).not.toContain("(1)");
692 expect(text()).not.toContain("Done");
693 expect(text()).not.toContain("running");
694 expect(text()).not.toContain("Worker");
695 await act(async () =>
696 renderer.root.findByProps({ type: "button", "aria-expanded": true }).props.onClick(),
697 );
698 expect(text()).not.toContain("Checker");
699 expect(text()).toContain("Previous agents (1)");
700 await act(async () =>
701 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
702 );
703 expect(text()).toContain("Checker");
704
705 state.projection = {
706 ...projection,
707 subagents: Array.from({ length: 8 }, (_, index) => ({
708 ...agent,
709 id: `running-agent-${index}`,
710 childThreadId: `running-child-${index}`,
711 })),
712 };
713 await act(async () => renderer.update(cloneElement(panel)));
714 expect(text()).toContain("Lineage · 8 running");
715
716 state.projection = {
717 ...projection,
718 subagents: Array.from({ length: 8 }, (_, index) => ({
719 ...agent,
720 id: `old-agent-${index}`,
721 childThreadId: `old-child-${index}`,
722 status: index === 7 ? "failed" : "completed",
723 title: `Old agent ${index}`,
724 result: index === 7 ? "Earlier build failed" : "Done",
725 completedAt: DateTime.makeUnsafe("2026-09-16T12:02:15Z"),
726 })),
727 };
728 await act(async () => renderer.update(cloneElement(panel)));
729 expect(text()).toContain("1 failed");
730 expect(text()).not.toContain("Old agent 7");
731 await act(async () =>
732 renderer.root
733 .findAllByType("button")
734 .find((button) => button.children.includes("Show "))!
735 .props.onClick(),
736 );
737 expect(text()).toContain("Old agent 7");
738
739 state.projection = {
740 ...projection,
741 subagents: [{ ...agent, childThreadId: null }],
742 };
743 await act(async () => renderer.update(cloneElement(panel)));
744 expect(text()).toContain("Lineage · 1 running");
745});
746
747it("shows readable models and only differing workspace details in agent tooltips", async () => {
748 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
749 state.showTooltips = true;
750 const parent = {
751 id: "parent",
752 projectId: "main",
753 worktreePath: null,
754 lineage: {},
755 activeProviderThreadId: null,
756 };
757 const child = {
758 id: "child",
759 projectId: "main",
760 worktreePath: null as string | null,
761 branch: null as string | null,
762 title: "Worker",
763 modelSelection: {
764 instanceId: "codex",
765 model: "gpt-5.4",
766 options: [{ id: "reasoningEffort", value: "high" }] as ModelSelection["options"],
767 },
768 lineage: { parentThreadId: "parent", relationshipToParent: "subagent" },
769 };
770 state.projects = [
771 { id: "main", environmentId: "test", title: "Main", workspaceRoot: "/main" },
772 {
773 id: "other",
774 environmentId: "elsewhere",
775 title: "Wrong environment",
776 workspaceRoot: "/wrong",
777 },
778 { id: "other", environmentId: "test", title: "Other project", workspaceRoot: "/other" },
779 ];
780 state.shells = [{ environmentId: "test", source: child }];
781 state.configs.set("test", {
782 providers: [
783 {
784 instanceId: "codex",
785 driver: "codex",
786 models: [
787 { slug: "gpt-5.4", name: "My GPT model", shortName: "My GPT", aliases: ["model-alias"] },
788 ],
789 },
790 ],
791 });
792 const projection = {
793 thread: parent,
794 runs: [],
795 providerThreads: [],
796 providerSessions: [],
797 contextTransfers: [],
798 subagents: [
799 {
800 id: "agent",
801 childThreadId: "child",
802 origin: "app_owned",
803 driver: "codex",
804 providerInstanceId: "codex",
805 title: "Worker",
806 prompt: "Check",
807 model: "gpt-5.4",
808 status: "pending",
809 startedAt: null,
810 completedAt: null,
811 updatedAt: DateTime.makeUnsafe("2026-09-16T12:00:00Z"),
812 },
813 ],
814 };
815 state.projection = projection;
816 const panel = (
817 <ThreadRelationshipsPanel
818 environmentId={EnvironmentId.make("test")}
819 threadId={ThreadId.make("parent")}
820 />
821 );
822 await act(async () => {
823 renderer = create(panel);
824 });
825 const text = (visibleOnly = false) => {
826 const read = (node: ReactTestInstance | string): string => {
827 if (typeof node === "string") return node;
828 if (visibleOnly && node.props.className === "sr-only") return "";
829 return node.children.map(read).join("");
830 };
831 return read(renderer.root);
832 };
833 const rerender = async (source = child) => {
834 state.shells = [{ environmentId: "test", source: { ...source } }];
835 await act(async () => renderer.update(cloneElement(panel)));
836 };
837 const nativeProjection = {
838 ...projection,
839 subagents: [{ ...projection.subagents[0], origin: "provider_native" }],
840 };
841 expect(text()).toContain("My GPT · high");
842 state.projection = nativeProjection;
843 await act(async () => renderer.update(cloneElement(panel)));
844 expect(text()).toContain("My GPT");
845 expect(text()).not.toContain("My GPT · high");
846 state.projection = projection;
847 await act(async () => renderer.update(cloneElement(panel)));
848 expect(text()).not.toContain("Tokens");
849 expect(text()).not.toContain("Open subagent");
850 expect(text()).not.toContain("Project");
851 expect(text()).not.toContain("Worktree");
852 expect(text()).not.toContain("Workspace");
853
854 for (const [model, expected] of [
855 [null, "Not reported"],
856 ["", "Not reported"],
857 [" ", "Not reported"],
858 ["model-alias", "My GPT"],
859 ["gpt-5.5", "GPT-5.5"],
860 ["custom/model-v1", "custom/model-v1"],
861 ] as const) {
862 state.projection = {
863 ...projection,
864 subagents: [{ ...projection.subagents[0], model }],
865 };
866 await act(async () => renderer.update(cloneElement(panel)));
867 expect(text()).toContain(expected);
868 if (!model?.trim() || model === "gpt-5.5" || model === "custom/model-v1") {
869 expect(text()).not.toContain(" · high");
870 } else {
871 expect(text()).toContain(`${expected} · high`);
872 }
873 expect(text()).not.toContain("Unknown");
874 if (!model?.trim()) expect(text()).not.toContain("My GPT");
875 }
876
877 for (const driver of [
878 "codex",
879 "claudeAgent",
880 "cursor",
881 "opencode",
882 "grok",
883 "antigravity",
884 "pi",
885 "acpRegistry",
886 ]) {
887 state.projection = {
888 ...projection,
889 subagents: [{ ...projection.subagents[0], driver, model: null }],
890 };
891 await act(async () => renderer.update(cloneElement(panel)));
892 expect(text()).toContain("Not reported");
893 expect(text()).not.toContain("My GPT");
894 }
895
896 state.projection = projection;
897 for (const [options, expected] of [
898 [[{ id: "effort", value: "max" }], " · max"],
899 [[{ id: "reasoning", value: "low" }], " · low"],
900 [[{ id: "variant", value: "high" }], " · high"],
901 [[{ id: "reasoningEffort", value: "none" }], " · none"],
902 [[{ id: "reasoningEffort", value: true }], ""],
903 [[{ id: "serviceTier", value: "fast" }], ""],
904 [[], ""],
905 [undefined, ""],
906 ] as const) {
907 child.modelSelection.options = options;
908 await rerender();
909 expect(text()).toContain(`My GPT${expected}`);
910 if (!expected) expect(text()).not.toContain("My GPT ·");
911 }
912 const speedConfig = state.configs.get("test");
913 const serviceTier: ProviderOptionDescriptor = {
914 id: "serviceTier",
915 label: "Service Tier",
916 type: "select",
917 currentValue: "priority",
918 options: [
919 { id: "default", label: "Standard", isDefault: true },
920 { id: "priority", label: "Fast" },
921 { id: "ultrafast", label: "Ultrafast" },
922 { id: "flex", label: "Flex" },
923 ],
924 };
925 const fastMode: ProviderOptionDescriptor = {
926 id: "fastMode",
927 label: "Fast Mode",
928 type: "boolean",
929 currentValue: true,
930 };
931 for (const [driver, descriptor, value, iconLabel] of [
932 ["codex", serviceTier, "default", ""],
933 ["codex", serviceTier, "priority", "Fast mode on"],
934 ["codex", serviceTier, "ultrafast", "Ultrafast mode on"],
935 ["codex", serviceTier, "flex", ""],
936 ["codex", serviceTier, "unknown", ""],
937 [
938 "codex",
939 { ...serviceTier, options: serviceTier.options.filter(({ id }) => id !== "ultrafast") },
940 "ultrafast",
941 "",
942 ],
943 ["codex", serviceTier, true, ""],
944 ["codex", serviceTier, undefined, ""],
945 ["claudeAgent", fastMode, true, "Fast mode on"],
946 ["claudeAgent", fastMode, false, ""],
947 ["cursor", fastMode, true, "Fast mode on"],
948 ["cursor", fastMode, false, ""],
949 ["opencode", fastMode, true, "Fast mode on"],
950 ["cursor", fastMode, "true", ""],
951 ["cursor", serviceTier, "priority", ""],
952 ] as const) {
953 state.configs.set("test", {
954 providers: [
955 {
956 instanceId: "codex",
957 driver,
958 models: [
959 {
960 slug: "gpt-5.4",
961 name: "My GPT",
962 capabilities: { optionDescriptors: [descriptor] },
963 },
964 ],
965 },
966 ],
967 });
968 child.modelSelection.options = [
969 { id: "reasoningEffort", value: "high" },
970 ...(value === undefined ? [] : [{ id: descriptor.id, value }]),
971 ];
972 await rerender();
973 expect(text(true)).toContain("My GPT · high");
974 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
975 expect(text()).toContain(`My GPT · ${iconLabel}high`);
976 if (!iconLabel) expect(text()).not.toContain("mode on");
977 child.modelSelection.options = child.modelSelection.options.filter(
978 ({ id }) => id !== "reasoningEffort",
979 );
980 await rerender();
981 expect(text()).not.toContain(" · high");
982 expect(text(true)).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex/);
983 if (iconLabel) expect(text()).toContain(iconLabel);
984 else expect(text()).not.toContain("mode on");
985 }
986 state.configs.set("test", {
987 providers: [
988 {
989 instanceId: "codex",
990 driver: "codex",
991 displayName: "Work account",
992 models: [
993 {
994 slug: "gpt-5.4",
995 name: "My GPT",
996 capabilities: { optionDescriptors: [serviceTier] },
997 },
998 ],
999 },
1000 {
1001 instanceId: "codex_personal",
1002 driver: "codex",
1003 displayName: "Personal account",
1004 models: [],
1005 },
1006 ],
1007 });
1008 child.modelSelection.options = [
1009 { id: "reasoningEffort", value: "high" },
1010 { id: "serviceTier", value: "priority" },
1011 ];
1012 await rerender();
1013 expect(text(true)).toContain("My GPT · Work account · high");
1014 expect(text()).toContain("My GPT · Work account · Fast mode onhigh");
1015 expect(text()).not.toContain("Personal account");
1016 state.projection = nativeProjection;
1017 await act(async () => renderer.update(cloneElement(panel)));
1018 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
1019 state.projection = projection;
1020 for (const modelSelection of [
1021 { ...child.modelSelection, instanceId: "other" },
1022 { ...child.modelSelection, model: "gpt-5.5" },
1023 ]) {
1024 await rerender({ ...child, modelSelection });
1025 expect(text()).not.toMatch(/Fast|Ultrafast|Normal|Standard|Flex| · high/);
1026 }
1027 state.shells = [];
1028 for (const status of ["running", "completed", "failed"] as const) {
1029 state.projection = {
1030 ...projection,
1031 subagents: [
1032 {
1033 ...projection.subagents[0],
1034 origin: "provider_native",
1035 status,
1036 modelSelection: {
1037 instanceId: "codex",
1038 model: "gpt-5.4",
1039 options: [
1040 { id: "reasoningEffort", value: "low" },
1041 { id: "serviceTier", value: "ultrafast" },
1042 ],
1043 },
1044 },
1045 ],
1046 };
1047 await act(async () => renderer.update(cloneElement(panel)));
1048 if (status === "completed") {
1049 await act(async () =>
1050 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
1051 );
1052 }
1053 expect(text()).toContain("My GPT · Work account · Ultrafast mode onlow");
1054 expect(text()).not.toContain(" · high");
1055 }
1056 await act(async () =>
1057 renderer.root.findByProps({ type: "button", "aria-expanded": true }).props.onClick(),
1058 );
1059 state.projection = projection;
1060 state.configs.set("test", speedConfig);
1061 child.modelSelection.options = [{ id: "reasoningEffort", value: "high" }];
1062 await rerender({ ...child, modelSelection: { ...child.modelSelection, instanceId: "other" } });
1063 expect(text()).not.toContain("My GPT ·");
1064 const config = state.configs.get("test");
1065 state.configs.clear();
1066 await act(async () => renderer.update(cloneElement(panel)));
1067 expect(text()).toContain("GPT-5.4");
1068 expect(text()).not.toContain("GPT-5.4 ·");
1069 await rerender();
1070 expect(text()).toContain("GPT-5.4 · high");
1071 state.configs.set("test", config);
1072 for (const model of ["custom/model-v1", "custom/model-v2"]) {
1073 state.projection = {
1074 ...projection,
1075 subagents: [{ ...projection.subagents[0], model }],
1076 };
1077 await rerender({
1078 ...child,
1079 modelSelection: { ...child.modelSelection, model: "custom/model-v1" },
1080 });
1081 expect(text().includes(`${model} · high`)).toBe(model === "custom/model-v1");
1082 }
1083
1084 state.projection = {
1085 ...projection,
1086 subagents: [
1087 {
1088 ...projection.subagents[0],
1089 progress: "Checking the latest changes",
1090 result: "Old intermediate result",
1091 },
1092 ],
1093 };
1094 await act(async () => renderer.update(cloneElement(panel)));
1095 expect(text()).toContain("Checking the latest changes");
1096 expect(text()).not.toContain("Old intermediate result");
1097 const result = "Final checks passed. " + "More detail. ".repeat(50) + "Hidden tail";
1098 state.projection = {
1099 ...projection,
1100 subagents: [
1101 { ...projection.subagents[0], status: "failed", progress: "Stale progress", result },
1102 ],
1103 };
1104 await act(async () => renderer.update(cloneElement(panel)));
1105 await act(async () =>
1106 renderer.root.findByProps({ type: "button", "aria-expanded": false }).props.onClick(),
1107 );
1108 expect(text()).toContain("Final checks passed.");
1109 expect(text()).not.toContain("Stale progress");
1110 expect(text()).not.toContain("Hidden tail");
1111 expect(text()).not.toContain(result);
1112 state.projection = projection;
1113
1114 child.worktreePath = "/main/worktrees/checker";
1115 await rerender();
1116 expect(text()).toContain("Worktree");
1117 expect(text()).toContain("checker");
1118 expect(text()).not.toContain("/main/worktrees");
1119 expect(text()).not.toContain("Project");
1120
1121 child.branch = "fix/checker";
1122 await rerender();
1123 expect(text()).toContain("Branch");
1124 expect(text()).toContain("fix/checker");
1125 expect(text()).not.toContain("Worktree");
1126 expect(text()).not.toContain("/main/worktrees");
1127
1128 child.projectId = "other";
1129 child.worktreePath = null;
1130 child.branch = null;
1131 await rerender();
1132 expect(text()).toContain("Other project");
1133 expect(text()).toContain("Workspace");
1134 expect(text()).toContain("other");
1135 expect(text()).not.toContain("/other");
1136 expect(text()).not.toContain("Wrong environment");
1137 expect(text()).not.toContain("Worktree");
1138
1139 state.shells = [];
1140 state.configs.clear();
1141 await act(async () => renderer.update(cloneElement(panel)));
1142 expect(text()).toContain("GPT-5.4");
1143 expect(text()).not.toContain("GPT-5.4 ·");
1144 expect(text()).not.toContain("gpt-5.4");
1145 expect(text()).not.toContain("Project");
1146 expect(text()).not.toContain("Workspace");
1147
1148 for (const [driver, model, expected] of [
1149 ["codex", "gpt-5.3-codex-spark", "GPT-5.3-Codex-Spark"],
1150 ["codex", "custom/model-v2", "custom/model-v2"],
1151 ["claudeAgent", "gpt-5.4", "GPT-5.4"],
1152 ["claudeAgent", "claude-opus-4-6", "Claude Opus 4.6"],
1153 ["cursor", "composer-2", "Composer 2"],
1154 ["grok", "grok-4-fast", "Grok 4 Fast"],
1155 ["antigravity", "gemini-3.8-flash-high", "Gemini 3.8 Flash High"],
1156 ["opencode", "anthropic/claude-sonnet-4-6", "anthropic/Claude Sonnet 4.6"],
1157 ["codex", null, "Not reported"],
1158 ] as const) {
1159 state.projection = {
1160 ...projection,
1161 subagents: [{ ...projection.subagents[0], driver, model }],
1162 };
1163 await act(async () => renderer.update(cloneElement(panel)));
1164 expect(text()).toContain(expected);
1165 }
1166});
1167
1168it("shows the parent's own visible status as parent and child activity change", async () => {
1169 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1170 const environmentId = EnvironmentId.make("test");
1171 const parent = {
1172 id: "parent",
1173 title: "Parent conversation",
1174 status: "completed",
1175 activityRunStatus: "running",
1176 lineage: { parentThreadId: null, relationshipToParent: null },
1177 };
1178 const child = {
1179 id: "child",
1180 title: "Current fork",
1181 status: "completed",
1182 lineage: { parentThreadId: "parent", relationshipToParent: "fork" },
1183 };
1184 state.projection = {
1185 thread: { ...child, activeProviderThreadId: null },
1186 runs: [],
1187 providerThreads: [],
1188 providerSessions: [],
1189 contextTransfers: [],
1190 subagents: [],
1191 };
1192 const shells = (parentSource: typeof parent, childSource: typeof child) => [
1193 { environmentId, source: parentSource },
1194 { environmentId, source: childSource },
1195 ];
1196 state.shells = shells(parent, child);
1197 const panel = (
1198 <ThreadRelationshipsPanel environmentId={environmentId} threadId={ThreadId.make("child")} />
1199 );
1200 await act(async () => {
1201 renderer = create(panel);
1202 });
1203 const visibleText = () =>
1204 renderer.root
1205 .findAll((node) => typeof node.type === "string" && node.props.className !== "sr-only")
1206 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
1207 .join(" ");
1208 expect(visibleText()).toContain("Parent conversation");
1209 expect(visibleText()).toContain("Running");
1210 state.shells = shells(
1211 { ...parent, activityRunStatus: "completed" },
1212 { ...child, status: "running" },
1213 );
1214 await act(async () => renderer.update(cloneElement(panel)));
1215 expect(visibleText()).toContain("Done");
1216 expect(visibleText()).not.toContain("Running");
1217});
1218
1219it.each(["source", "target"])(
1220 "shows transfer lifecycle states when viewing the %s thread",
1221 async (currentThreadId) => {
1222 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1223 const environmentId = EnvironmentId.make("test");
1224 const threads = ["source", "target"].map((id) => ({
1225 id,
1226 title: `${id} conversation`,
1227 status: "running",
1228 activityRunStatus: "running",
1229 lineage: { parentThreadId: null, relationshipToParent: null },
1230 }));
1231 state.shells = threads.map((source) => ({ environmentId, source }));
1232 const labels: Record<OrchestrationV2ContextTransfer["status"], string> = {
1233 pending: "Queued",
1234 resolved_native: "Resolved (native)",
1235 resolved_portable: "Resolved (portable)",
1236 failed: "Failed",
1237 consumed: "Consumed",
1238 superseded: "Superseded",
1239 };
1240 const panel = (
1241 <ThreadRelationshipsPanel
1242 environmentId={environmentId}
1243 threadId={ThreadId.make(currentThreadId)}
1244 />
1245 );
1246 for (const [status, label] of Object.entries(labels)) {
1247 state.projection = {
1248 thread: {
1249 ...threads.find((thread) => thread.id === currentThreadId),
1250 activeProviderThreadId: null,
1251 },
1252 runs: [],
1253 providerThreads: [],
1254 providerSessions: [],
1255 subagents: [],
1256 contextTransfers: [{ sourceThreadId: "source", targetThreadId: "target", status }],
1257 };
1258 await act(async () => {
1259 if (status === "pending") renderer = create(panel);
1260 else renderer.update(cloneElement(panel));
1261 });
1262 const visibleText = renderer.root
1263 .findAll((node) => typeof node.type === "string")
1264 .flatMap((node) => node.children.filter((child) => typeof child === "string"))
1265 .join(" ");
1266 expect(visibleText).toContain(
1267 currentThreadId === "source" ? "target conversation" : "source conversation",
1268 );
1269 expect(visibleText).toContain(label);
1270 expect(visibleText).not.toContain("Unknown");
1271 expect(visibleText).not.toContain("Running");
1272 }
1273 },
1274);