Skip to content
Source
Sign inSign up

threadRelationships.test.ts

441 lines14.0 KB

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 resolveMergeBackTargetThreadId,
10 threadRelationshipRowStatus,
11} from "./threadRelationships.ts";
12​
13describe("thread relationships", () => {
14 it.each([
15 ["running", "completed"],
16 ["completed", "running"],
17 ])("keeps parent %s independent of child %s", (parentStatus, childStatus) => {
18 const parent = ThreadId.make("parent");
19 const child = ThreadId.make("child");
20 const graph = deriveThreadRelationshipGraph({
21 threads: [
22 {
23 id: parent,
24 activityRunStatus: parentStatus,
25 status: "cancelled",
26 lineage: { parentThreadId: null },
27 },
28 {
29 id: child,
30 activityRunStatus: childStatus,
31 lineage: { parentThreadId: parent, relationshipToParent: "fork" },
32 },
33 ] as never,
34 projection: null,
35 });
36 expect(threadRelationshipRowStatus(graph, immediateThreadRelationships(graph, child)[0]!)).toBe(
37 parentStatus,
38 );
39 expect(
40 threadRelationshipRowStatus(graph, immediateThreadRelationships(graph, parent)[0]!),
41 ).toBe(childStatus);
42 });
43​
44 it("does not label a missing parent with its child's running status", () => {
45 const parent = ThreadId.make("missing-parent");
46 const child = ThreadId.make("child");
47 const graph = deriveThreadRelationshipGraph({
48 threads: [
49 {
50 id: child,
51 status: "running",
52 lineage: { parentThreadId: parent, relationshipToParent: "subagent" },
53 },
54 ] as never,
55 projection: null,
56 });
57 expect(
58 threadRelationshipRowStatus(graph, immediateThreadRelationships(graph, child)[0]!),
59 ).toBeNull();
60 });
61​
62 it.each(["fork", "subagent"] as const)(
63 "keeps the parent edge of an opened %s omitted from the shell",
64 (relationshipToParent) => {
65 const parent = ThreadId.make("parent");
66 const child = ThreadId.make("omitted-child");
67 const projection = {
68 thread: {
69 id: child,
70 forkedFrom:
71 relationshipToParent === "fork"
72 ? { type: "run", threadId: parent, runId: "source-run" }
73 : null,
74 lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent },
75 },
76 subagents: [],
77 contextTransfers: [],
78 } as never;
79 const graph = deriveThreadRelationshipGraph({ threads: [], projection });
80 expect(immediateThreadRelationships(graph, child)).toEqual([
81 expect.objectContaining({
82 threadId: parent,
83 edge: expect.objectContaining({
84 sourceThreadId: parent,
85 targetThreadId: child,
86 kind: relationshipToParent,
87 }),
88 }),
89 ]);
90 expect(resolveMergeBackTargetThreadId(projection)).toBe(
91 relationshipToParent === "fork" ? parent : null,
92 );
93 },
94 );
95​
96 it("keeps an older activity run visible over a newer cancelled run", () => {
97 const parent = ThreadId.make("thread-parent");
98 const child = ThreadId.make("thread-child");
99 const graph = deriveThreadRelationshipGraph({
100 threads: [
101 {
102 id: child,
103 title: "Active child",
104 activityRunStatus: "running",
105 status: "cancelled",
106 forkedFrom: { type: "run", threadId: parent, runId: "run-parent" },
107 lineage: {
108 rootThreadId: parent,
109 parentThreadId: parent,
110 relationshipToParent: "subagent",
111 },
112 },
113 ] as never,
114 projection: null,
115 });
116​
117 expect(graph.edges).toEqual([
118 expect.objectContaining({ targetThreadId: child, status: "running" }),
119 ]);
120 });
121​
122 it("keeps missing parents and cycles navigable without recursive traversal", () => {
123 const root = ThreadId.make("thread-root");
124 const child = ThreadId.make("thread-child");
125 const missing = ThreadId.make("thread-missing");
126 const graph = deriveThreadRelationshipGraph({
127 threads: [
128 {
129 id: root,
130 title: "Root",
131 status: "completed",
132 forkedFrom: { type: "run", threadId: child, runId: "run-cycle" },
133 lineage: { rootThreadId: root, parentThreadId: child, relationshipToParent: "fork" },
134 },
135 {
136 id: child,
137 title: "Child",
138 status: "completed",
139 forkedFrom: { type: "run", threadId: missing, runId: "run-missing" },
140 lineage: { rootThreadId: root, parentThreadId: missing, relationshipToParent: "fork" },
141 },
142 ] as never,
143 projection: null,
144 });
145​
146 expect(graph.nodes.get(missing)?.missing).toBe(true);
147 expect(immediateThreadRelationships(graph, root).map(({ threadId }) => threadId)).toEqual([
148 child,
149 ]);
150 });
151​
152 it("combines subagent and transfer edges with archived shell state", () => {
153 const parent = ThreadId.make("thread-parent");
154 const child = ThreadId.make("thread-child");
155 const transferTarget = ThreadId.make("thread-transfer");
156 const graph = deriveThreadRelationshipGraph({
157 threads: [
158 {
159 id: parent,
160 title: "Parent",
161 status: "completed",
162 archivedAt: null,
163 forkedFrom: null,
164 lineage: { rootThreadId: parent, parentThreadId: null, relationshipToParent: null },
165 },
166 {
167 id: child,
168 title: "Subagent",
169 status: "completed",
170 archivedAt: "2026-06-24T00:00:00.000Z",
171 forkedFrom: null,
172 lineage: {
173 rootThreadId: parent,
174 parentThreadId: parent,
175 relationshipToParent: "subagent",
176 },
177 },
178 ] as never,
179 projection: {
180 thread: { id: parent },
181 subagents: [{ childThreadId: child, status: "completed" }],
182 contextTransfers: [
183 {
184 sourceThreadId: child,
185 targetThreadId: transferTarget,
186 status: "completed",
187 },
188 ],
189 } as never,
190 });
191​
192 expect(graph.nodes.get(child)?.thread?.archivedAt).not.toBeNull();
193 expect(graph.nodes.get(transferTarget)?.missing).toBe(true);
194 expect(graph.edges).toEqual(
195 expect.arrayContaining([
196 expect.objectContaining({
197 sourceThreadId: parent,
198 targetThreadId: child,
199 kind: "subagent",
200 }),
201 expect.objectContaining({
202 sourceThreadId: child,
203 targetThreadId: transferTarget,
204 kind: "transfer",
205 }),
206 ]),
207 );
208 });
209​
210 it("keeps the live shell when an archived snapshot contains the same thread id", () => {
211 const parent = ThreadId.make("thread-parent");
212 const staleParent = ThreadId.make("thread-stale-parent");
213 const child = ThreadId.make("thread-child");
214 const liveChild = {
215 id: child,
216 title: "Live child",
217 status: "running",
218 archivedAt: null,
219 forkedFrom: { type: "run", threadId: parent, runId: "run-live" },
220 lineage: {
221 rootThreadId: parent,
222 parentThreadId: parent,
223 relationshipToParent: "fork",
224 },
225 } as const;
226 const staleArchivedChild = {
227 ...liveChild,
228 title: "Stale archived child",
229 status: "completed",
230 archivedAt: "2026-06-24T00:00:00.000Z",
231 forkedFrom: { type: "run", threadId: staleParent, runId: "run-stale" },
232 lineage: {
233 rootThreadId: staleParent,
234 parentThreadId: staleParent,
235 relationshipToParent: "fork",
236 },
237 } as const;
238​
239 const graph = deriveThreadRelationshipGraph({
240 threads: [liveChild, staleArchivedChild] as never,
241 projection: null,
242 });
243​
244 expect(graph.nodes.get(child)?.thread).toMatchObject({
245 title: "Live child",
246 status: "running",
247 archivedAt: null,
248 });
249 expect(graph.edges).toEqual([
250 expect.objectContaining({
251 sourceThreadId: parent,
252 targetThreadId: child,
253 }),
254 ]);
255 expect(graph.nodes.has(staleParent)).toBe(false);
256 });
257​
258 it("resolves merge-back only for forks and prefers the recorded fork source", () => {
259 const source = ThreadId.make("thread-source");
260 const fallbackParent = ThreadId.make("thread-parent");
261 const fork = ThreadId.make("thread-fork");
262​
263 expect(
264 resolveMergeBackTargetThreadId({
265 thread: {
266 id: fork,
267 forkedFrom: { type: "run", threadId: source, runId: "run-source" },
268 lineage: {
269 rootThreadId: source,
270 parentThreadId: fallbackParent,
271 relationshipToParent: "fork",
272 },
273 },
274 } as never),
275 ).toBe(source);
276 expect(
277 resolveMergeBackTargetThreadId({
278 thread: {
279 id: fork,
280 forkedFrom: null,
281 lineage: {
282 rootThreadId: source,
283 parentThreadId: fallbackParent,
284 relationshipToParent: "fork",
285 },
286 },
287 } as never),
288 ).toBe(fallbackParent);
289 expect(
290 resolveMergeBackTargetThreadId({
291 thread: {
292 id: fork,
293 forkedFrom: null,
294 lineage: {
295 rootThreadId: source,
296 parentThreadId: fallbackParent,
297 relationshipToParent: "subagent",
298 },
299 },
300 } as never),
301 ).toBeNull();
302 });
303});
304​
305const current = ThreadId.make("thread-current");
306​
307function forkShell(input: {
308 readonly id: ThreadId;
309 readonly parentThreadId: ThreadId | null;
310 readonly createdAt?: string;
311 readonly updatedAt?: string;
312 readonly status?: string;
313}) {
314 return {
315 id: input.id,
316 title: input.id,
317 status: input.status ?? "completed",
318 archivedAt: null,
319 forkedFrom:
320 input.parentThreadId === null
321 ? null
322 : { type: "run", threadId: input.parentThreadId, runId: `run-${input.id}` },
323 lineage: {
324 rootThreadId: input.parentThreadId ?? input.id,
325 parentThreadId: input.parentThreadId,
326 relationshipToParent: input.parentThreadId === null ? null : "fork",
327 },
328 createdAt: input.createdAt === undefined ? undefined : DateTime.makeUnsafe(input.createdAt),
329 updatedAt: DateTime.makeUnsafe(input.updatedAt ?? "2026-06-20T00:00:00.000Z"),
330 };
332​
333function orderedLineageIds(input: {
334 readonly threads: ReadonlyArray<unknown>;
335 readonly mergeTargetThreadId?: ThreadId | null;
336 readonly projection?: unknown;
337}): ReadonlyArray<ThreadId> {
338 const graph = deriveThreadRelationshipGraph({
339 threads: input.threads as never,
340 projection: (input.projection ?? null) as never,
341 });
342 return orderWebThreadLineageRows({
343 graph,
344 rows: immediateThreadRelationships(graph, current),
345 currentThreadId: current,
346 mergeTargetThreadId: input.mergeTargetThreadId ?? null,
347 }).map(({ threadId }) => threadId);
349​
350describe("web thread lineage ordering", () => {
351 it("does not reorder when related-thread activity arrives", () => {
352 const first = ThreadId.make("thread-fork-a");
353 const second = ThreadId.make("thread-fork-b");
354 const third = ThreadId.make("thread-fork-c");
355 const before = orderedLineageIds({
356 threads: [
357 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
358 forkShell({ id: first, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
359 forkShell({ id: second, parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z" }),
360 forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }),
361 ],
362 });
363​
364 const after = orderedLineageIds({
365 threads: [
366 forkShell({
367 id: second,
368 parentThreadId: current,
369 createdAt: "2026-06-03T00:00:00.000Z",
370 updatedAt: "2026-07-30T00:00:00.000Z",
371 status: "running",
372 }),
373 forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }),
374 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
375 forkShell({
376 id: first,
377 parentThreadId: current,
378 createdAt: "2026-06-02T00:00:00.000Z",
379 updatedAt: "2026-07-31T00:00:00.000Z",
380 status: "failed",
381 }),
382 ],
383 });
384​
385 expect(before).toEqual([third, second, first]);
386 expect(after).toEqual(before);
387 });
388​
389 it("pins the parent first and a distinct merge-back target second", () => {
390 const parent = ThreadId.make("thread-parent");
391 const mergeTarget = ThreadId.make("thread-merge-target");
392 const newestFork = ThreadId.make("thread-newest-fork");
393​
394 expect(
395 orderedLineageIds({
396 threads: [
397 forkShell({ id: parent, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
398 forkShell({
399 id: current,
400 parentThreadId: parent,
401 createdAt: "2026-06-02T00:00:00.000Z",
402 }),
403 forkShell({
404 id: mergeTarget,
405 parentThreadId: current,
406 createdAt: "2026-06-03T00:00:00.000Z",
407 }),
408 forkShell({
409 id: newestFork,
410 parentThreadId: current,
411 createdAt: "2026-06-09T00:00:00.000Z",
412 }),
413 ],
414 mergeTargetThreadId: mergeTarget,
415 }),
416 ).toEqual([parent, mergeTarget, newestFork]);
417 });
418​
419 it("sinks missing nodes and shells without a decoded createdAt", () => {
420 const missingTransfer = ThreadId.make("thread-missing-transfer");
421 const undated = ThreadId.make("thread-undated-fork");
422 const dated = ThreadId.make("thread-dated-fork");
423​
424 expect(
425 orderedLineageIds({
426 threads: [
427 forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }),
428 forkShell({ id: undated, parentThreadId: current }),
429 forkShell({ id: dated, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }),
430 ],
431 projection: {
432 thread: { id: current },
433 subagents: [],
434 contextTransfers: [
435 { sourceThreadId: current, targetThreadId: missingTransfer, status: "completed" },
436 ],
437 },
438 }),
439 ).toEqual([dated, missingTransfer, undated]);
440 });
441});