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<unknown>; readonly mergeTargetThreadId?: ThreadId | null; readonly projection?: unknown;}): ReadonlyArray<ThreadId> { 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]); });});
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<unknown>;
readonly mergeTargetThreadId?: ThreadId | null;
readonly projection?: unknown;
}): ReadonlyArray<ThreadId> {
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]);
});
});