import { describe, expect, it } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { deriveThreadRelationshipGraph, immediateThreadRelationships, orderWebThreadLineageRows, relatedThreadIds, resolveMergeBackTargetThreadId, walkThreadRelationships, } from "./threadRelationships.ts"; describe("thread relationships", () => { it("keeps an older activity run visible over a newer cancelled run", () => { const parent = ThreadId.make("thread-parent"); const child = ThreadId.make("thread-child"); const graph = deriveThreadRelationshipGraph({ threads: [ { id: child, title: "Active child", activityRunStatus: "running", status: "cancelled", forkedFrom: { type: "run", threadId: parent, runId: "run-parent" }, lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent: "subagent", }, }, ] as never, projection: null, }); expect(graph.edges).toEqual([ expect.objectContaining({ targetThreadId: child, status: "running" }), ]); }); it("keeps missing parents and cycles navigable without recursive traversal", () => { const root = ThreadId.make("thread-root"); const child = ThreadId.make("thread-child"); const missing = ThreadId.make("thread-missing"); const graph = deriveThreadRelationshipGraph({ threads: [ { id: root, title: "Root", status: "completed", forkedFrom: { type: "run", threadId: child, runId: "run-cycle" }, lineage: { rootThreadId: root, parentThreadId: child, relationshipToParent: "fork" }, }, { id: child, title: "Child", status: "completed", forkedFrom: { type: "run", threadId: missing, runId: "run-missing" }, lineage: { rootThreadId: root, parentThreadId: missing, relationshipToParent: "fork" }, }, ] as never, projection: null, }); expect(graph.nodes.get(missing)?.missing).toBe(true); expect(relatedThreadIds(graph, root)).toEqual([child]); expect(relatedThreadIds(graph, child)).toEqual([root, missing]); expect( walkThreadRelationships(graph, root).map(({ threadId, depth }) => [threadId, depth]), ).toEqual([ [child, 1], [missing, 2], ]); expect(immediateThreadRelationships(graph, root).map(({ threadId }) => threadId)).toEqual([ child, ]); }); it("combines subagent and transfer edges with archived shell state", () => { const parent = ThreadId.make("thread-parent"); const child = ThreadId.make("thread-child"); const transferTarget = ThreadId.make("thread-transfer"); const graph = deriveThreadRelationshipGraph({ threads: [ { id: parent, title: "Parent", status: "completed", archivedAt: null, forkedFrom: null, lineage: { rootThreadId: parent, parentThreadId: null, relationshipToParent: null }, }, { id: child, title: "Subagent", status: "completed", archivedAt: "2026-06-24T00:00:00.000Z", forkedFrom: null, lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent: "subagent", }, }, ] as never, projection: { thread: { id: parent }, subagents: [{ childThreadId: child, status: "completed" }], contextTransfers: [ { sourceThreadId: child, targetThreadId: transferTarget, status: "completed", }, ], } as never, }); expect(graph.nodes.get(child)?.thread?.archivedAt).not.toBeNull(); expect(graph.nodes.get(transferTarget)?.missing).toBe(true); expect(graph.edges).toEqual( expect.arrayContaining([ expect.objectContaining({ sourceThreadId: parent, targetThreadId: child, kind: "subagent", }), expect.objectContaining({ sourceThreadId: child, targetThreadId: transferTarget, kind: "transfer", }), ]), ); }); it("keeps the live shell when an archived snapshot contains the same thread id", () => { const parent = ThreadId.make("thread-parent"); const staleParent = ThreadId.make("thread-stale-parent"); const child = ThreadId.make("thread-child"); const liveChild = { id: child, title: "Live child", status: "running", archivedAt: null, forkedFrom: { type: "run", threadId: parent, runId: "run-live" }, lineage: { rootThreadId: parent, parentThreadId: parent, relationshipToParent: "fork", }, } as const; const staleArchivedChild = { ...liveChild, title: "Stale archived child", status: "completed", archivedAt: "2026-06-24T00:00:00.000Z", forkedFrom: { type: "run", threadId: staleParent, runId: "run-stale" }, lineage: { rootThreadId: staleParent, parentThreadId: staleParent, relationshipToParent: "fork", }, } as const; const graph = deriveThreadRelationshipGraph({ threads: [liveChild, staleArchivedChild] as never, projection: null, }); expect(graph.nodes.get(child)?.thread).toMatchObject({ title: "Live child", status: "running", archivedAt: null, }); expect(graph.edges).toEqual([ expect.objectContaining({ sourceThreadId: parent, targetThreadId: child, }), ]); expect(graph.nodes.has(staleParent)).toBe(false); }); it("resolves merge-back only for forks and prefers the recorded fork source", () => { const source = ThreadId.make("thread-source"); const fallbackParent = ThreadId.make("thread-parent"); const fork = ThreadId.make("thread-fork"); expect( resolveMergeBackTargetThreadId({ thread: { id: fork, forkedFrom: { type: "run", threadId: source, runId: "run-source" }, lineage: { rootThreadId: source, parentThreadId: fallbackParent, relationshipToParent: "fork", }, }, } as never), ).toBe(source); expect( resolveMergeBackTargetThreadId({ thread: { id: fork, forkedFrom: null, lineage: { rootThreadId: source, parentThreadId: fallbackParent, relationshipToParent: "fork", }, }, } as never), ).toBe(fallbackParent); expect( resolveMergeBackTargetThreadId({ thread: { id: fork, forkedFrom: null, lineage: { rootThreadId: source, parentThreadId: fallbackParent, relationshipToParent: "subagent", }, }, } as never), ).toBeNull(); }); }); const current = ThreadId.make("thread-current"); function forkShell(input: { readonly id: ThreadId; readonly parentThreadId: ThreadId | null; readonly createdAt?: string; readonly updatedAt?: string; readonly status?: string; }) { return { id: input.id, title: input.id, status: input.status ?? "completed", archivedAt: null, forkedFrom: input.parentThreadId === null ? null : { type: "run", threadId: input.parentThreadId, runId: `run-${input.id}` }, lineage: { rootThreadId: input.parentThreadId ?? input.id, parentThreadId: input.parentThreadId, relationshipToParent: input.parentThreadId === null ? null : "fork", }, createdAt: input.createdAt === undefined ? undefined : DateTime.makeUnsafe(input.createdAt), updatedAt: DateTime.makeUnsafe(input.updatedAt ?? "2026-06-20T00:00:00.000Z"), }; } function orderedLineageIds(input: { readonly threads: ReadonlyArray; readonly mergeTargetThreadId?: ThreadId | null; readonly projection?: unknown; }): ReadonlyArray { const graph = deriveThreadRelationshipGraph({ threads: input.threads as never, projection: (input.projection ?? null) as never, }); return orderWebThreadLineageRows({ graph, rows: immediateThreadRelationships(graph, current), currentThreadId: current, mergeTargetThreadId: input.mergeTargetThreadId ?? null, }).map(({ threadId }) => threadId); } describe("web thread lineage ordering", () => { it("orders sibling forks newest created first", () => { const oldest = ThreadId.make("thread-fork-oldest"); const middle = ThreadId.make("thread-fork-middle"); const newest = ThreadId.make("thread-fork-newest"); expect( orderedLineageIds({ threads: [ forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), forkShell({ id: oldest, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z", }), forkShell({ id: middle, parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z", }), forkShell({ id: newest, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z", }), ], }), ).toEqual([newest, middle, oldest]); }); it("does not reorder when related-thread activity arrives", () => { const first = ThreadId.make("thread-fork-a"); const second = ThreadId.make("thread-fork-b"); const third = ThreadId.make("thread-fork-c"); const before = orderedLineageIds({ threads: [ forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), forkShell({ id: first, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }), forkShell({ id: second, parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z" }), forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }), ], }); // The shell snapshot arrives ordered by `updated_at`, and a client-side // `thread.updated` re-appends the shell it replaces, so both the input // order and the mutable timestamps move under us while the panel is open. const after = orderedLineageIds({ threads: [ forkShell({ id: second, parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z", updatedAt: "2026-07-30T00:00:00.000Z", status: "running", }), forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-04T00:00:00.000Z" }), forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), forkShell({ id: first, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z", updatedAt: "2026-07-31T00:00:00.000Z", status: "failed", }), ], }); expect(before).toEqual([third, second, first]); expect(after).toEqual(before); }); it("pins the parent first and a distinct merge-back target second", () => { // The panel keeps its two action rows in place regardless of creation time: // parent first, then a merge-back target that is not the parent. The two // diverge while a shell update and its projection disagree about the fork // source, which is exactly when a moving row would misfire a merge. const parent = ThreadId.make("thread-parent"); const mergeTarget = ThreadId.make("thread-merge-target"); const newestFork = ThreadId.make("thread-newest-fork"); expect( orderedLineageIds({ threads: [ forkShell({ id: parent, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), forkShell({ id: current, parentThreadId: parent, createdAt: "2026-06-02T00:00:00.000Z", }), forkShell({ id: mergeTarget, parentThreadId: current, createdAt: "2026-06-03T00:00:00.000Z", }), forkShell({ id: newestFork, parentThreadId: current, createdAt: "2026-06-09T00:00:00.000Z", }), ], mergeTargetThreadId: mergeTarget, }), ).toEqual([parent, mergeTarget, newestFork]); }); it("sinks missing nodes and shells without a decoded createdAt", () => { const missingTransfer = ThreadId.make("thread-missing-transfer"); const undated = ThreadId.make("thread-undated-fork"); const dated = ThreadId.make("thread-dated-fork"); expect( orderedLineageIds({ threads: [ forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), forkShell({ id: undated, parentThreadId: current }), forkShell({ id: dated, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }), ], projection: { thread: { id: current }, subagents: [], contextTransfers: [ { sourceThreadId: current, targetThreadId: missingTransfer, status: "completed" }, ], }, }), ).toEqual([dated, missingTransfer, undated]); }); it("breaks equal creation times by thread id", () => { const first = ThreadId.make("thread-fork-a"); const second = ThreadId.make("thread-fork-b"); const third = ThreadId.make("thread-fork-c"); expect( orderedLineageIds({ threads: [ forkShell({ id: current, parentThreadId: null, createdAt: "2026-06-01T00:00:00.000Z" }), forkShell({ id: third, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }), forkShell({ id: first, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }), forkShell({ id: second, parentThreadId: current, createdAt: "2026-06-02T00:00:00.000Z" }), ], }), ).toEqual([first, second, third]); }); });