1import { describe, expect, it } from "vite-plus/test";
2import { ThreadId } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4
5import {
6 deriveThreadRelationshipGraph,
7 immediateThreadRelationships,
8 orderWebThreadLineageRows,
9 relatedThreadIds,
10 resolveMergeBackTargetThreadId,
11 walkThreadRelationships,
12 threadRelationshipRowStatus,
13} from "./threadRelationships.ts";
14
15describe("thread relationships", () => {
16 it.each([
17 ["running", "completed"],
18 ["completed", "running"],
19 ["running", "running"],
20 ["completed", "completed"],
21 ["waiting", "failed"],
22 ["interrupted", "pending"],
23 ])("keeps parent %s independent of child %s", (parentStatus, childStatus) => {
24 const parent = ThreadId.make("parent");
25 const child = ThreadId.make("child");
26 const graph = deriveThreadRelationshipGraph({
27 threads: [
28 {
29 id: parent,
30 activityRunStatus: parentStatus,
31 status: "cancelled",
32 lineage: { parentThreadId: null },
33 },
34 {
35 id: child,
36 activityRunStatus: childStatus,
37 lineage: { parentThreadId: parent, relationshipToParent: "fork" },
38 },
39 ] as never,
40 projection: null,
41 });
42 expect(threadRelationshipRowStatus(graph, immediateThreadRelationships(graph, child)[0]!)).toBe(
43 parentStatus,
44 );
45 expect(
46 threadRelationshipRowStatus(graph, immediateThreadRelationships(graph, parent)[0]!),
47 ).toBe(childStatus);
48 });
49
50 it("does not label a missing parent with its child's running status", () => {
51 const parent = ThreadId.make("missing-parent");
52 const child = ThreadId.make("child");
53 const graph = deriveThreadRelationshipGraph({
54 threads: [
55 {
56 id: child,
57 status: "running",
58 lineage: { parentThreadId: parent, relationshipToParent: "subagent" },
59 },
60 ] as never,
61 projection: null,
62 });
63 expect(
64 threadRelationshipRowStatus(graph, immediateThreadRelationships(graph, child)[0]!),
65 ).toBeNull();
66 });
67
68 it("keeps an older activity run visible over a newer cancelled run", () => {
69 const parent = ThreadId.make("thread-parent");
70 const child = ThreadId.make("thread-child");
71 const graph = deriveThreadRelationshipGraph({
72 threads: [
73 {
74 id: child,
75 title: "Active child",
76 activityRunStatus: "running",
77 status: "cancelled",
78 forkedFrom: { type: "run", threadId: parent, runId: "run-parent" },
79 lineage: {
80 rootThreadId: parent,
81 parentThreadId: parent,
82 relationshipToParent: "subagent",
83 },
84 },
85 ] as never,
86 projection: null,
87 });
88
89 expect(graph.edges).toEqual([
90 expect.objectContaining({ targetThreadId: child, status: "running" }),
91 ]);
92 });
93
94 it("keeps missing parents and cycles navigable without recursive traversal", () => {
95 const root = ThreadId.make("thread-root");
96 const child = ThreadId.make("thread-child");
97 const missing = ThreadId.make("thread-missing");
98 const graph = deriveThreadRelationshipGraph({
99 threads: [
100 {
101 id: root,
102 title: "Root",
103 status: "completed",
104 forkedFrom: { type: "run", threadId: child, runId: "run-cycle" },
105 lineage: { rootThreadId: root, parentThreadId: child, relationshipToParent: "fork" },
106 },
107 {
108 id: child,
109 title: "Child",
110 status: "completed",
111 forkedFrom: { type: "run", threadId: missing, runId: "run-missing" },
112 lineage: { rootThreadId: root, parentThreadId: missing, relationshipToParent: "fork" },
113 },
114 ] as never,
115 projection: null,
116 });
117
118 expect(graph.nodes.get(missing)?.missing).toBe(true);
119 expect(relatedThreadIds(graph, root)).toEqual([child]);
120 expect(relatedThreadIds(graph, child)).toEqual([root, missing]);
121 expect(
122 walkThreadRelationships(graph, root).map(({ threadId, depth }) => [threadId, depth]),
123 ).toEqual([
124 [child, 1],
125 [missing, 2],
126 ]);
127 expect(immediateThreadRelationships(graph, root).map(({ threadId }) => threadId)).toEqual([
128 child,
129 ]);
130 });
131
132 it("combines subagent and transfer edges with archived shell state", () => {
133 const parent = ThreadId.make("thread-parent");
134 const child = ThreadId.make("thread-child");
135 const transferTarget = ThreadId.make("thread-transfer");
136 const graph = deriveThreadRelationshipGraph({
137 threads: [
138 {
139 id: parent,
140 title: "Parent",
141 status: "completed",
142 archivedAt: null,
143 forkedFrom: null,
144 lineage: { rootThreadId: parent, parentThreadId: null, relationshipToParent: null },
145 },
146 {
147 id: child,
148 title: "Subagent",
149 status: "completed",
150 archivedAt: "2026-06-24T00:00:00.000Z",
151 forkedFrom: null,
152 lineage: {
153 rootThreadId: parent,
154 parentThreadId: parent,
155 relationshipToParent: "subagent",
156 },
157 },
158 ] as never,
159 projection: {
160 thread: { id: parent },
161 subagents: [{ childThreadId: child, status: "completed" }],
162 contextTransfers: [
163 {
164 sourceThreadId: child,
165 targetThreadId: transferTarget,
166 status: "completed",
167 },
168 ],
169 } as never,
170 });
171
172 expect(graph.nodes.get(child)?.thread?.archivedAt).not.toBeNull();
173 expect(graph.nodes.get(transferTarget)?.missing).toBe(true);
174 expect(graph.edges).toEqual(
175 expect.arrayContaining([
176 expect.objectContaining({
177 sourceThreadId: parent,
178 targetThreadId: child,
179 kind: "subagent",
180 }),
181 expect.objectContaining({
182 sourceThreadId: child,
183 targetThreadId: transferTarget,
184 kind: "transfer",
185 }),
186 ]),
187 );
188 });
189
190 it.each([
191 ["running", "running", "running"],
192 ["waiting", "completed", "waiting"],
193 [null, "queued", "queued"],
194 [null, "failed", "failed"],
195 [null, "interrupted", "interrupted"],
196 [null, "cancelled", "cancelled"],
197 [null, "completed", "completed"],
198 [null, "rolled_back", "interrupted"],
199 ])(
200 "shows child activity %s and latest status %s as %s after its delegated task settled",
201 (childActivity, childStatus, expected) => {
202 const parent = ThreadId.make("thread-parent");
203 const child = ThreadId.make("thread-child");
204 const graph = deriveThreadRelationshipGraph({
205 threads: [
206 { id: parent, status: "completed", forkedFrom: null, lineage: { parentThreadId: null } },
207 {
208 id: child,
209 status: childStatus,
210 activityRunStatus: childActivity,
211 latestRunId: "run-child-followup",
212 forkedFrom: null,
213 lineage: { parentThreadId: parent, relationshipToParent: "subagent" },
214 },
215 ] as never,
216 projection: {
217 thread: { id: parent },
218 subagents: [{ childThreadId: child, status: "completed" }],
219 contextTransfers: [],
220 } as never,
221 });
222
223 const row = immediateThreadRelationships(graph, parent)[0]!;
224 expect(threadRelationshipRowStatus(graph, row)).toBe(expected);
225 },
226 );
227
228 it("keeps the live shell when an archived snapshot contains the same thread id", () => {
229 const parent = ThreadId.make("thread-parent");
230 const staleParent = ThreadId.make("thread-stale-parent");
231 const child = ThreadId.make("thread-child");
232 const liveChild = {
233 id: child,
234 title: "Live child",
235 status: "running",
236 archivedAt: null,
237 forkedFrom: { type: "run", threadId: parent, runId: "run-live" },
238 lineage: {
239 rootThreadId: parent,
240 parentThreadId: parent,
241 relationshipToParent: "fork",
242 },
243 } as const;
244 const staleArchivedChild = {
245 ...liveChild,
246 title: "Stale archived child",
247 status: "completed",
248 archivedAt: "2026-06-24T00:00:00.000Z",
249 forkedFrom: { type: "run", threadId: staleParent, runId: "run-stale" },
250 lineage: {
251 rootThreadId: staleParent,
252 parentThreadId: staleParent,
253 relationshipToParent: "fork",
254 },
255 } as const;
256
257 const graph = deriveThreadRelationshipGraph({
258 threads: [liveChild, staleArchivedChild] as never,
259 projection: null,
260 });
261
262 expect(graph.nodes.get(child)?.thread).toMatchObject({
263 title: "Live child",
264 status: "running",
265 archivedAt: null,
266 });
267 expect(graph.edges).toEqual([
268 expect.objectContaining({
269 sourceThreadId: parent,
270 targetThreadId: child,
271 }),
272 ]);
273 expect(graph.nodes.has(staleParent)).toBe(false);
274 });
275
276 it("resolves merge-back only for forks and prefers the recorded fork source", () => {
277 const source = ThreadId.make("thread-source");
278 const fallbackParent = ThreadId.make("thread-parent");
279 const fork = ThreadId.make("thread-fork");
280
281 expect(
282 resolveMergeBackTargetThreadId({
283 thread: {
284 id: fork,
285 forkedFrom: { type: "run", threadId: source, runId: "run-source" },
286 lineage: {
287 rootThreadId: source,
288 parentThreadId: fallbackParent,
289 relationshipToParent: "fork",
290 },
291 },
292 } as never),
293 ).toBe(source);
294 expect(
295 resolveMergeBackTargetThreadId({
296 thread: {
297 id: fork,
298 forkedFrom: null,
299 lineage: {
300 rootThreadId: source,
301 parentThreadId: fallbackParent,
302 relationshipToParent: "fork",
303 },
304 },
305 } as never),
306 ).toBe(fallbackParent);
307 expect(
308 resolveMergeBackTargetThreadId({
309 thread: {
310 id: fork,
311 forkedFrom: null,
312 lineage: {
313 rootThreadId: source,
314 parentThreadId: fallbackParent,
315 relationshipToParent: "subagent",
316 },
317 },
318 } as never),
319 ).toBeNull();
320 });
321});
322
323const current = ThreadId.make("thread-current");
324
325function forkShell(input: {
326 readonly id: ThreadId;
327 readonly parentThreadId: ThreadId | null;
328 readonly createdAt?: string;
329 readonly updatedAt?: string;
330 readonly status?: string;
331}) {
332 return {
333 id: input.id,
334 title: input.id,
335 status: input.status ?? "completed",
336 archivedAt: null,
337 forkedFrom:
338 input.parentThreadId === null
339 ? null
340 : { type: "run", threadId: input.parentThreadId, runId: `run-${input.id}` },
341 lineage: {
342 rootThreadId: input.parentThreadId ?? input.id,
343 parentThreadId: input.parentThreadId,
344 relationshipToParent: input.parentThreadId === null ? null : "fork",
345 },
346 createdAt: input.createdAt === undefined ? undefined : DateTime.makeUnsafe(input.createdAt),
347 updatedAt: DateTime.makeUnsafe(input.updatedAt ?? "2026-06-20T00:00:00.000Z"),
348 };
349}
350
351function orderedLineageIds(input: {
352 readonly threads: ReadonlyArray<unknown>;
353 readonly mergeTargetThreadId?: ThreadId | null;
354 readonly projection?: unknown;
355}): ReadonlyArray<ThreadId> {
356 const graph = deriveThreadRelationshipGraph({
357 threads: input.threads as never,
358 projection: (input.projection ?? null) as never,
359 });
360 return orderWebThreadLineageRows({
361 graph,
362 rows: immediateThreadRelationships(graph, current),
363 currentThreadId: current,
364 mergeTargetThreadId: input.mergeTargetThreadId ?? null,
365 }).map(({ threadId }) => threadId);
366}
367
368describe("web thread lineage ordering", () => {
369 it("orders sibling forks newest created first", () => {
370 const oldest = ThreadId.make("thread-fork-oldest");
371 const middle = ThreadId.make("thread-fork-middle");
372 const newest = ThreadId.make("thread-fork-newest");
373
374 expect(
375 orderedLineageIds({
376 threads: [
377 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
378 forkShell({
379 id: oldest,
380 parentThreadId: current,
381 createdAt: "2026-06-02T00:00:00.000Z",
382 }),
383 forkShell({
384 id: middle,
385 parentThreadId: current,
386 createdAt: "2026-06-03T00:00:00.000Z",
387 }),
388 forkShell({
389 id: newest,
390 parentThreadId: current,
391 createdAt: "2026-06-04T00:00:00.000Z",
392 }),
393 ],
394 }),
395 ).toEqual([newest, middle, oldest]);
396 });
397
398 it("does not reorder when related-thread activity arrives", () => {
399 const first = ThreadId.make("thread-fork-a");
400 const second = ThreadId.make("thread-fork-b");
401 const third = ThreadId.make("thread-fork-c");
402 const before = orderedLineageIds({
403 threads: [
404 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
405 forkShell({ id: first, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
406 forkShell({ id: second, parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z" }),
407 forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }),
408 ],
409 });
410
411 // The shell snapshot arrives ordered by `updated_at`, and a client-side
412 // `thread.updated` re-appends the shell it replaces, so both the input
413 // order and the mutable timestamps move under us while the panel is open.
414 const after = orderedLineageIds({
415 threads: [
416 forkShell({
417 id: second,
418 parentThreadId: current,
419 createdAt: "2026-06-03T00:00:00.000Z",
420 updatedAt: "2026-07-30T00:00:00.000Z",
421 status: "running",
422 }),
423 forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }),
424 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
425 forkShell({
426 id: first,
427 parentThreadId: current,
428 createdAt: "2026-06-02T00:00:00.000Z",
429 updatedAt: "2026-07-31T00:00:00.000Z",
430 status: "failed",
431 }),
432 ],
433 });
434
435 expect(before).toEqual([third, second, first]);
436 expect(after).toEqual(before);
437 });
438
439 it("pins the parent first and a distinct merge-back target second", () => {
440 // The panel keeps its two action rows in place regardless of creation time:
441 // parent first, then a merge-back target that is not the parent. The two
442 // diverge while a shell update and its projection disagree about the fork
443 // source, which is exactly when a moving row would misfire a merge.
444 const parent = ThreadId.make("thread-parent");
445 const mergeTarget = ThreadId.make("thread-merge-target");
446 const newestFork = ThreadId.make("thread-newest-fork");
447
448 expect(
449 orderedLineageIds({
450 threads: [
451 forkShell({ id: parent, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
452 forkShell({
453 id: current,
454 parentThreadId: parent,
455 createdAt: "2026-06-02T00:00:00.000Z",
456 }),
457 forkShell({
458 id: mergeTarget,
459 parentThreadId: current,
460 createdAt: "2026-06-03T00:00:00.000Z",
461 }),
462 forkShell({
463 id: newestFork,
464 parentThreadId: current,
465 createdAt: "2026-06-09T00:00:00.000Z",
466 }),
467 ],
468 mergeTargetThreadId: mergeTarget,
469 }),
470 ).toEqual([parent, mergeTarget, newestFork]);
471 });
472
473 it("sinks missing nodes and shells without a decoded createdAt", () => {
474 const missingTransfer = ThreadId.make("thread-missing-transfer");
475 const undated = ThreadId.make("thread-undated-fork");
476 const dated = ThreadId.make("thread-dated-fork");
477
478 expect(
479 orderedLineageIds({
480 threads: [
481 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
482 forkShell({ id: undated, parentThreadId: current }),
483 forkShell({ id: dated, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
484 ],
485 projection: {
486 thread: { id: current },
487 subagents: [],
488 contextTransfers: [
489 { sourceThreadId: current, targetThreadId: missingTransfer, status: "completed" },
490 ],
491 },
492 }),
493 ).toEqual([dated, missingTransfer, undated]);
494 });
495
496 it("breaks equal creation times by thread id", () => {
497 const first = ThreadId.make("thread-fork-a");
498 const second = ThreadId.make("thread-fork-b");
499 const third = ThreadId.make("thread-fork-c");
500
501 expect(
502 orderedLineageIds({
503 threads: [
504 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
505 forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
506 forkShell({ id: first, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
507 forkShell({ id: second, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
508 ],
509 }),
510 ).toEqual([first, second, third]);
511 });
512});