Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apps/web/src/components/AgentThreadTree.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import type { RuntimeSubagent } from "@t3tools/client-runtime/state/subagentRuntime";
import { describe, expect, it } from "vite-plus/test";

import {
assignAgentSections,
childThreadsByParent,
parseRouterJobTag,
routerJobTagOfMessages,
threadBreadcrumbLinks,
threadShellStatus,
toggleExpandedThread,
type RouterJobTag,
} from "./AgentThreadTree.logic";

const PLANNER = "7b0736c2-3dec-4adb-aa18-6d93d15db108";

function agent(id: string, status: RuntimeSubagent["status"] = "running"): RuntimeSubagent {
return { id, title: id, status } as RuntimeSubagent;
}

function shell(id: string, createdAt: string, extra: { archivedAt?: string | null } = {}) {
return { id, title: `title ${id}`, createdAt, archivedAt: extra.archivedAt ?? null };
}

describe("router job tags", () => {
it("reads the tag Model Router opens every job thread with", () => {
expect(
parseRouterJobTag(
`[model-router job router-80-release-t3 worker seq 2 on route muse-spark-xhigh-free; planner thread ${PLANNER}]\n\nThis turn belongs to ...`,
),
).toEqual({
requestId: "router-80-release-t3",
kind: "worker seq 2",
route: "muse-spark-xhigh-free",
plannerThreadId: PLANNER,
});
});

it("ignores user prompts that merely mention a job", () => {
expect(
parseRouterJobTag("Implement Issue #17. [model-router job x y on route z; planner thread p]"),
).toBeNull();
expect(
routerJobTagOfMessages([
{
role: "assistant",
text: `[model-router job a dispatcher on route luna/max; planner thread ${PLANNER}]`,
},
{ role: "user", text: "Spawned by spawn_thread" },
]),
).toBeNull();
});
});

describe("section assignment", () => {
const dispatcherTag: RouterJobTag = {
requestId: "job-1",
kind: "dispatcher",
route: "luna/max",
plannerThreadId: PLANNER,
};
const workerTag: RouterJobTag = { ...dispatcherTag, kind: "worker seq 1", route: "muse-free" };

it("keeps own spawns direct and groups router threads by job with the dispatcher route", () => {
const sections = assignAgentSections(
[
agent("native-subagent"),
agent(`sub.${PLANNER}.w1`, "idle"),
agent(`sub.${PLANNER}.own`),
agent(`sub.${PLANNER}.d1`, "failed"),
agent(`sub.${PLANNER}.other`, "completed"),
],
new Map([
[`sub.${PLANNER}.w1`, workerTag],
[`sub.${PLANNER}.own`, null],
[`sub.${PLANNER}.d1`, dispatcherTag],
[`sub.${PLANNER}.other`, { ...dispatcherTag, requestId: "job-2" }],
]),
);
expect(sections.directAgents.map((a) => a.id)).toEqual([
"native-subagent",
`sub.${PLANNER}.own`,
]);
expect(sections.prismJobs.map((job) => [job.requestId, job.route, job.status])).toEqual([
["job-1", "luna/max", "failed"],
["job-2", "luna/max", "completed"],
]);
expect(sections.prismJobs[0]!.agents.map((a) => a.id)).toEqual([
`sub.${PLANNER}.w1`,
`sub.${PLANNER}.d1`,
]);
});

it("leaves not-yet-read threads in Direct spawns", () => {
const sections = assignAgentSections([agent(`sub.${PLANNER}.w1`)], new Map());
expect(sections.directAgents).toHaveLength(1);
expect(sections.prismJobs).toEqual([]);
});
});

describe("child thread tree", () => {
it("nests live children under their parent in spawn order", () => {
const children = childThreadsByParent([
shell(PLANNER, "2026-09-25T10:00:00Z"),
shell(`sub.${PLANNER}.b`, "2026-09-25T10:02:00Z"),
shell(`sub.${PLANNER}.a`, "2026-09-25T10:01:00Z"),
shell(`sub.${PLANNER}.gone`, "2026-09-25T10:03:00Z", { archivedAt: "2026-09-25T11:00:00Z" }),
shell(`sub.sub.${PLANNER}.a.x`, "2026-09-25T10:04:00Z"),
]);
expect(children.get(PLANNER)?.map((s) => s.id)).toEqual([
`sub.${PLANNER}.a`,
`sub.${PLANNER}.b`,
]);
expect(children.get(`sub.${PLANNER}.a`)?.map((s) => s.id)).toEqual([`sub.sub.${PLANNER}.a.x`]);
});

it("starts collapsed and toggles only the clicked row", () => {
let expanded: ReadonlySet<string> = new Set();
expanded = toggleExpandedThread(expanded, "a");
expanded = toggleExpandedThread(expanded, "b");
expect([...expanded]).toEqual(["a", "b"]);
expanded = toggleExpandedThread(expanded, "a");
expect([...expanded]).toEqual(["b"]);
});

it("reads a shell's status like the Agents panel does", () => {
expect(threadShellStatus({ session: null, latestTurn: null })).toBe("pending");
expect(threadShellStatus({ session: null, latestTurn: { state: "completed" } } as never)).toBe(
"idle",
);
expect(threadShellStatus({ session: { status: "running" }, latestTurn: null } as never)).toBe(
"running",
);
expect(threadShellStatus({ session: null, latestTurn: { state: "error" } } as never)).toBe(
"failed",
);
});
});

describe("breadcrumb links", () => {
const shells = [
shell(PLANNER, "2026-09-25T10:00:00Z"),
shell(`sub.${PLANNER}.a`, "2026-09-25T10:01:00Z"),
shell(`sub.${PLANNER}.b`, "2026-09-25T10:02:00Z"),
];

it("links a child thread to its parent and siblings", () => {
const links = threadBreadcrumbLinks(`sub.${PLANNER}.b`, shells);
expect(links?.parent.id).toBe(PLANNER);
expect(links?.siblings.map((s) => s.id)).toEqual([`sub.${PLANNER}.a`, `sub.${PLANNER}.b`]);
});

it("prefers a real parentThreadId over the id convention", () => {
const links = threadBreadcrumbLinks("child", [
...shells,
{ ...shell("child", "2026-09-25T10:05:00Z"), parentThreadId: PLANNER },
]);
expect(links?.parent.id).toBe(PLANNER);
});

it("shows no crumbs for user threads or children whose parent is gone", () => {
expect(threadBreadcrumbLinks(PLANNER, shells)).toBeNull();
expect(
threadBreadcrumbLinks("sub.missing.a", [shell("sub.missing.a", "2026-09-25T10:00:00Z")]),
).toBeNull();
});
});
192 changes: 192 additions & 0 deletions apps/web/src/components/AgentThreadTree.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Agents panel structure for child threads (toolboxmd/t3code#17): which
* section a spawn belongs to, which threads nest under a row, and the
* parent/sibling links for a child thread's header breadcrumb.
*
* Pure functions over thread shells and the subagent fold, so every client
* can reuse them and tests need no rendering.
*/
import type {
RuntimeSubagent,
RuntimeSubagentStatus,
} from "@t3tools/client-runtime/state/subagentRuntime";
import type { OrchestrationThreadShell } from "@t3tools/contracts";

import { parentThreadIdOf } from "./subagentThreads";

/**
* Model Router (planned name Prism) opens every job thread with
* `[model-router job <request id> <kind> on route <route>; planner thread <id>]`.
*/
export interface RouterJobTag {
readonly requestId: string;
readonly kind: string;
readonly route: string;
readonly plannerThreadId: string;
}

const ROUTER_JOB_TAG = /^\[model-router job (\S+) (.+?) on route (\S+); planner thread (\S+?)\]/;

export function parseRouterJobTag(text: string): RouterJobTag | null {
const match = ROUTER_JOB_TAG.exec(text.trimStart());
if (!match) return null;
const [, requestId, kind, route, plannerThreadId] = match;
return { requestId: requestId!, kind: kind!, route: route!, plannerThreadId: plannerThreadId! };
}

/** Recognizes a router thread from its loaded user messages, oldest first. */
export function routerJobTagOfMessages(
messages: ReadonlyArray<{ readonly role: string; readonly text: string }>,
): RouterJobTag | null {
for (const message of messages) {
if (message.role !== "user") continue;
const tag = parseRouterJobTag(message.text);
if (tag) return tag;
}
return null;
}

/** Threads carry their parent in the `sub.<parent>.<suffix>` id; a real field wins. */
function parentThreadIdOfShell(shell: {
readonly id: string;
readonly parentThreadId?: string | null;
}): string | null {
return shell.parentThreadId ?? parentThreadIdOf(shell.id);
}

interface TreeShell {
readonly id: string;
readonly createdAt: string;
readonly archivedAt: string | null;
readonly parentThreadId?: string | null;
}

/** Live child threads per parent id, in spawn order. */
export function childThreadsByParent<T extends TreeShell>(
shells: ReadonlyArray<T>,
): ReadonlyMap<string, ReadonlyArray<T>> {
const byParent = new Map<string, T[]>();
for (const shell of shells) {
if (shell.archivedAt !== null) continue;
const parentId = parentThreadIdOfShell(shell);
if (parentId === null) continue;
const siblings = byParent.get(parentId);
if (siblings) siblings.push(shell);
else byParent.set(parentId, [shell]);
}
for (const siblings of byParent.values()) {
siblings.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
return byParent;
}

export interface PrismJobGroup {
readonly requestId: string;
readonly route: string;
readonly status: RuntimeSubagentStatus;
readonly agents: ReadonlyArray<RuntimeSubagent>;
}

export interface AgentSections {
readonly directAgents: ReadonlyArray<RuntimeSubagent>;
readonly prismJobs: ReadonlyArray<PrismJobGroup>;
}

// Most urgent first: a job reads as working while any thread works.
const JOB_STATUS_PRECEDENCE: ReadonlyArray<RuntimeSubagentStatus> = [
"running",
"waiting",
"pending",
"failed",
"interrupted",
"cancelled",
"idle",
"completed",
];

function jobStatusOf(agents: ReadonlyArray<RuntimeSubagent>): RuntimeSubagentStatus {
for (const status of JOB_STATUS_PRECEDENCE) {
if (agents.some((agent) => agent.status === status)) return status;
}
return "pending";
}

/**
* Splits the panel's direct spawns: threads Model Router started for this
* thread go to "Prism spawns", grouped by job in first-seen order; everything
* this thread started itself stays in "Direct spawns".
*/
export function assignAgentSections(
directAgents: ReadonlyArray<RuntimeSubagent>,
routerTags: ReadonlyMap<string, RouterJobTag | null>,
): AgentSections {
const direct: RuntimeSubagent[] = [];
const jobs = new Map<string, { tags: RouterJobTag[]; agents: RuntimeSubagent[] }>();
for (const agent of directAgents) {
const tag = routerTags.get(agent.id) ?? null;
if (tag === null) {
direct.push(agent);
continue;
}
const job = jobs.get(tag.requestId);
if (job) {
job.tags.push(tag);
job.agents.push(agent);
} else {
jobs.set(tag.requestId, { tags: [tag], agents: [agent] });
}
}
return {
directAgents: direct,
prismJobs: [...jobs].map(([requestId, job]) => ({
requestId,
route: (job.tags.find((tag) => tag.kind === "dispatcher") ?? job.tags[0]!).route,
status: jobStatusOf(job.agents),
agents: job.agents,
})),
};
}

/** Coarse Agents-panel status for a thread known only by its shell. */
export function threadShellStatus(
shell: Pick<OrchestrationThreadShell, "session" | "latestTurn">,
): RuntimeSubagentStatus {
const session = shell.session?.status;
const turn = shell.latestTurn?.state;
if (session === "running" || session === "starting" || turn === "running") return "running";
if (session === "error" || turn === "error") return "failed";
if (session === "interrupted" || turn === "interrupted") return "interrupted";
if (turn === "completed") return "idle";
return "pending";
}

/** Toggles one row's children; nothing else changes, so nothing opens on its own. */
export function toggleExpandedThread(
expanded: ReadonlySet<string>,
threadId: string,
): ReadonlySet<string> {
const next = new Set(expanded);
if (!next.delete(threadId)) next.add(threadId);
return next;
}

export interface ThreadBreadcrumbLinks<T> {
readonly parent: T;
readonly siblings: ReadonlyArray<T>;
}

/**
* Parent and siblings (including the thread itself) for a child thread's
* header, or null for a thread the user started or whose parent is gone.
*/
export function threadBreadcrumbLinks<T extends TreeShell>(
threadId: string,
shells: ReadonlyArray<T>,
): ThreadBreadcrumbLinks<T> | null {
const self = shells.find((shell) => shell.id === threadId);
const parentId = self ? parentThreadIdOfShell(self) : parentThreadIdOf(threadId);
if (parentId === null) return null;
const parent = shells.find((shell) => shell.id === parentId);
if (!parent) return null;
return { parent, siblings: childThreadsByParent(shells).get(parentId) ?? [] };
}
Loading
Loading