feat(client): unified tasks view model + graph layout + api methods

- tasks-model.ts: one pre-order node list for graph AND tree modes —
  catalog walking, workflow-run attachment with member re-parenting and
  synthesis, team enrichment, per-parent settled-leaf fold aggregates.
- tasks-graph-layout.ts: dependency-free tidy-tree layered layout
  (bundle-purity: no graph library in the core bundle).
- api.ts: workflowsList/teamsView/teamsTaskCreate/teamsTaskUpdate with
  the wire result types mirrored from the host routes.
- 11 unit tests across model derivation, edge classification, fold, and
  layout geometry.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:14 +08:00
parent c445495f8b
commit fdc4520080
5 changed files with 741 additions and 1 deletions
+48 -1
View File
@@ -9,7 +9,16 @@
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarJobView, SidebarSessionEvent } from '../context-types.ts'
import type {
SidebarCreateTeamTaskRequest,
SidebarJobView,
SidebarSessionEvent,
SidebarTeamMemberView,
SidebarTeamTaskMutationResult,
SidebarTeamTaskView,
SidebarUpdateTeamTaskRequest,
} from '../context-types.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -113,6 +122,23 @@ export interface JobOutputResult {
/** The `subagents.live` response: running child id → latest activity. */
export type SubagentLiveResult = { live: Record<string, LastActivity> }
/** The `workflows.list` response: the tree's folded workflow runs. */
export type WorkflowsListResult = { runs: WorkflowRunView[] }
/** The `teams.view` response (structural degradation contract). */
export type TeamsViewResult =
| { available: false }
| { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null }
/** The `teams.taskCreate` request payload (minus the rootSessionId). */
export type TeamsTaskCreateRequest = SidebarCreateTeamTaskRequest
/** The `teams.taskUpdate` request payload (minus the rootSessionId). */
export type TeamsTaskUpdateRequest = SidebarUpdateTeamTaskRequest
/** The task-mutation result union (CAS conflict stays distinct). */
export type TeamsTaskMutationResult = SidebarTeamTaskMutationResult
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
* status, an unparseable body, or any shape other than `{ok: true, value}`
@@ -353,6 +379,27 @@ export const api = {
*/
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
/**
* The workflow runs of the whole tree, folded host-side from the
* `tool-workflow/*` session events (the same four types the official
* workflow-run panel folds). Empty when the tree never ran a workflow —
* absence is the normal case, never an error.
*/
workflowsList: (rootSessionId: string, signal?: AbortSignal) =>
call<WorkflowsListResult>('workflows.list', { rootSessionId }, signal),
/**
* The team led by the tree's root agent (experimental Agent Teams layer):
* `{available:false}` hides the whole block; `{team:null}` means the root
* leads no team. Poll-driven (the official panel is manual-refresh too).
*/
teamsView: (rootSessionId: string, signal?: AbortSignal) =>
call<TeamsViewResult>('teams.view', { rootSessionId }, signal),
/** Create one shared task on the root-led team. */
teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) =>
call<TeamsTaskMutationResult>('teams.taskCreate', { rootSessionId, ...req }),
/** CAS-mutate one shared task; conflicts ride the returned union. */
teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) =>
call<TeamsTaskMutationResult>('teams.taskUpdate', { rootSessionId, ...req }),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
+108
View File
@@ -0,0 +1,108 @@
/**
* The layered top-down layout of the Tasks graph canvas: a tidy-tree
* simplification — every node sits one row below its parent, siblings share
* a row in model order, and each parent is horizontally centered over its
* children's band. Pure and dependency-free (the bundle-purity rule forbids
* pulling a graph library into the core bundle; <100-node trees need no
* virtualisation), unit-testable in the node environment.
*/
import type { TasksNode } from './tasks-model.ts'
import { tasksEdges } from './tasks-model.ts'
/** Geometry constants of the canvas (px, pre-scale). */
export const GRAPH_NODE_W = 140
export const GRAPH_NODE_H = 48
/** Extra height of an agent node carrying a live line. */
export const GRAPH_LIVE_H = 16
export const GRAPH_GAP_X = 28
export const GRAPH_GAP_Y = 72
export const GRAPH_PAD = 16
/** One laid-out node. */
export interface GraphBox {
id: string
x: number
y: number
w: number
h: number
}
/** The layout result: one box per node plus the canvas extent. */
export interface GraphLayout {
boxes: ReadonlyMap<string, GraphBox>
width: number
height: number
}
/** The display height of one node (live lines grow agent rows). */
function nodeHeight(node: TasksNode): number {
if (node.kind === 'agent' && node.live !== undefined) return GRAPH_NODE_H + GRAPH_LIVE_H
return GRAPH_NODE_H
}
/**
* Lay out the model top-down. Nodes whose parent is missing (a fold node
* whose parent scrolled out of the model — defensive) attach to the first
* root. The algorithm is the classic two-walk tidy pass: a post-order walk
* accumulates each subtree's width, a pre-order walk assigns x so every
* parent centers over its children.
* @param nodes - the model's pre-order node list (roots = parentless rows).
*/
export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const roots = nodes.filter(node => node.parentId === undefined || !nodes.some(candidate => candidate.id === node.parentId))
const childrenOf = new Map<string, TasksNode[]>()
for (const edge of tasksEdges(nodes)) {
const child = nodes.find(node => node.id === edge.to)
if (child === undefined) continue
const list = childrenOf.get(edge.from)
if (list === undefined) childrenOf.set(edge.from, [child])
else list.push(child)
}
/** Post-order: the width of the subtree rooted at `node`. */
const subtreeWidth = (node: TasksNode): number => {
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return GRAPH_NODE_W
const band = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (children.length - 1)
return Math.max(GRAPH_NODE_W, band)
}
const boxes = new Map<string, GraphBox>()
/** Pre-order: place `node` so its band centers on `centerX`; `depth` is
* the row (the tallest ancestor row so far offsets uniformly). */
const place = (node: TasksNode, centerX: number, depth: number): void => {
const h = nodeHeight(node)
boxes.set(node.id, {
id: node.id,
x: Math.round(centerX - GRAPH_NODE_W / 2),
y: GRAPH_PAD + depth * (GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y),
w: GRAPH_NODE_W,
h,
})
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return
const total = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ GRAPH_GAP_X * (children.length - 1)
let cursor = centerX - total / 2
for (const child of children) {
const width = subtreeWidth(child)
place(child, cursor + width / 2, depth + 1)
cursor += width + GRAPH_GAP_X
}
}
// Single-root forest in practice (the topology root); the loop stays so a
// defensive orphan still lands inside the canvas extent.
let offset = GRAPH_PAD
for (const root of roots) {
const width = subtreeWidth(root)
place(root, offset + width / 2, 0)
offset += width + GRAPH_GAP_X
}
const allBoxes = [...boxes.values()]
const width = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_W, ...allBoxes.map(box => box.x + box.w + GRAPH_PAD))
const height = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_H, ...allBoxes.map(box => box.y + box.h + GRAPH_PAD))
return { boxes, width, height }
}
+329
View File
@@ -0,0 +1,329 @@
/**
* The unified view model of the Tasks page: one ordered node list the graph
* canvas AND the tree mode both render (same data, same fold state). Pure
* derivation over the sessions list feed (byId lineage + per-parent
* catalogs), the live activity map, the folded workflow runs, and the
* optional team view — kept framework-free for node-environment unit tests.
*
* Shape rules:
* - agent rows walk the lazy per-parent catalogs in pre-order (the same
* recursion the classic tree used); Side Chat threads and diagnostic rows
* never become nodes;
* - a workflow run hangs under its ORIGIN agent; member agents that already
* exist as the origin's catalog children are RE-PARENTED under the run
* node, members without a catalog row are synthesized from the run's own
* member data (so a finished run still shows its members);
* - team members enrich matching agent nodes in place (role/model/status);
* the lead's membership lands on the root node;
* - fold: per parent, settled agent LEAVES (state done/error, never the
* current session, never a workflow member's run node) collapse into one
* trailing `fold` node carrying the member ids and label previews.
*/
import type {
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarTeamMemberView,
} from '../context-types.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import { isSideThreadSummary } from './subagent-detect.ts'
/** Display state of one agent node (drives the dot + fold candidacy). */
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
/** One agent node (root, subagent, teammate, or synthesized workflow member). */
export interface TasksAgentNode {
kind: 'agent'
id: string
parentId?: string
/** Card line 1: durable label, else summary title, else the id. */
label: string
/** The summary's display title when it differs from the label. */
title?: string
mode?: 'one-shot' | 'continuable'
state: TasksNodeState
/** The catalog's raw activity word (the secondary line localizes it). */
activity: 'running' | 'inactive'
/** The on-screen session (the "you are here" marker). */
current: boolean
/** Live tail of a running child (the icon + tool + args line). */
live?: LastActivity
/** Team enrichment (membership view matched by session id). */
team?: {
role: 'lead' | 'teammate'
name: string
model?: string
status: SidebarTeamMemberView['status']
diagnostics: string[]
}
/** The catalog's durable children signal (fold candidacy's leaf test). */
hasChildren?: boolean
/** Synthesized from a workflow run's member row (no catalog entry). */
synthesized?: boolean
/** Jump target of the row (absent on the root node). */
childAddress?: SidebarSubagentAddress
}
/** One workflow run node (its members hang below as agent children). */
export interface TasksWorkflowNode {
kind: 'workflow'
id: string
parentId: string
run: WorkflowRunView
}
/** One fold aggregate node (settled leaves of one parent, collapsed). */
export interface TasksFoldNode {
kind: 'fold'
id: string
parentId: string
count: number
/** The folded session ids, in original order (expansion restores them). */
memberIds: string[]
/** Up to two label previews for the collapsed subtitle. */
previews: string[]
}
export type TasksNode = TasksAgentNode | TasksWorkflowNode | TasksFoldNode
/** Inputs of the model build (all already-resolved client mirrors). */
export interface TasksModelInput {
byId: Readonly<Record<string, SidebarSessionSummary>>
catalogs: Readonly<Record<string, SidebarSubagentCatalog | undefined>>
rootId: string
currentSessionId: string
live: Readonly<Record<string, LastActivity | undefined>>
runs: readonly WorkflowRunView[]
teamMembers: readonly SidebarTeamMemberView[]
/** Whether settled leaves collapse into fold nodes. */
folded: boolean
}
/** Human label of one catalog child (the classic rule). */
function childLabel(
entry: SidebarSubagentChildEntry,
summary: SidebarSessionSummary | undefined,
): string {
return entry.label ?? summary?.displayTitle ?? entry.id
}
/** Map a team member's runtime status onto the node display state. */
function teamState(status: SidebarTeamMemberView['status']): TasksNodeState {
switch (status) {
case 'running': return 'running'
case 'provisioning': return 'running'
case 'failed': return 'error'
case 'idle':
case 'inactive': return 'idle'
}
}
/** The workflow member's display state from its outcome (undefined = live). */
function memberOutcomeState(outcome: 'completed' | 'failed' | 'cancelled' | undefined): TasksNodeState {
switch (outcome) {
case undefined: return 'running'
case 'completed': return 'done'
case 'cancelled': return 'done'
case 'failed': return 'error'
}
}
/**
* Build the ordered (pre-order) node list of the Tasks page. The result is
* stable for a stable input set: catalog order is preserved, runs follow
* their origin's agent children in startedSeq order, and each fold node
* trails its parent's remaining children.
*/
export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const { byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded } = input
const teamById = new Map(teamMembers.map(member => [member.id, member]))
const runsByOrigin = new Map<string, WorkflowRunView[]>()
for (const run of runs) {
const list = runsByOrigin.get(run.originSessionId)
if (list === undefined) runsByOrigin.set(run.originSessionId, [run])
else list.push(run)
}
const teamOf = (id: string): TasksAgentNode['team'] => {
const member = teamById.get(id)
return member === undefined
? undefined
: { role: member.role, name: member.name, model: member.model, status: member.status, diagnostics: member.diagnostics }
}
const out: TasksNode[] = []
/** The agent children of one parent, in catalog order (side threads and
* diagnostics excluded), with workflow runs appended in start order. */
const appendChildren = (parentId: string): void => {
const catalog = catalogs[parentId]
const entries = catalog?.state === 'ready' ? catalog.entries : []
const agentChildren: TasksAgentNode[] = []
for (const entry of entries) {
if (entry.kind !== 'child') continue
const summary = byId[entry.id]
if (summary !== undefined && isSideThreadSummary(summary)) continue
if (entry.label?.startsWith('Side: ') ?? false) continue
const team = teamOf(entry.id)
const state: TasksNodeState = entry.activity === 'running'
? 'running'
: team !== undefined ? teamState(team.status) : 'done'
agentChildren.push({
kind: 'agent',
id: entry.id,
parentId,
label: childLabel(entry, summary),
...(summary?.displayTitle !== undefined && summary.displayTitle !== entry.label
? { title: summary.displayTitle } : {}),
mode: entry.mode,
state,
activity: entry.activity,
current: entry.id === currentSessionId,
...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}),
...(team !== undefined ? { team } : {}),
hasChildren: entry.hasChildren,
childAddress: { parentSessionId: parentId, childSessionId: entry.id, mode: entry.mode },
})
}
// Workflow runs of this agent: member agents with a catalog row are
// re-parented under the run; the rest are synthesized from run data.
const runNodes: TasksWorkflowNode[] = []
const memberNodes: TasksAgentNode[][] = []
for (const run of runsByOrigin.get(parentId) ?? []) {
const runNode: TasksWorkflowNode = { kind: 'workflow', id: `run:${run.runId}`, parentId, run }
runNodes.push(runNode)
const members: TasksAgentNode[] = []
for (const phase of run.phases) {
for (const member of phase.members) {
const existing = member.childId !== ''
? agentChildren.find(candidate => candidate.id === member.childId)
: undefined
if (existing !== undefined) {
existing.parentId = runNode.id
members.push(existing)
} else {
const childKnown = member.childId !== ''
members.push({
kind: 'agent',
id: childKnown ? member.childId : `wfmember:${run.runId}:${member.seq}`,
parentId: runNode.id,
label: member.label,
state: memberOutcomeState(member.outcome),
activity: member.outcome === undefined ? 'running' : 'inactive',
current: member.childId !== '' && member.childId === currentSessionId,
synthesized: true,
...(childKnown
? { childAddress: { parentSessionId: parentId, childSessionId: member.childId, mode: 'one-shot' as const } }
: {}),
})
}
}
}
memberNodes.push(members)
}
const visibleAgentChildren = agentChildren.filter(child => child.parentId === parentId)
// Fold: settled agent leaves of THIS parent collapse into one aggregate.
const kept: TasksAgentNode[] = []
const foldCandidates: TasksAgentNode[] = []
for (const child of visibleAgentChildren) {
// A leaf: no durable children (catalog signal) and no workflow run of
// its own — folding a parent would hide its live branch.
const isLeaf = child.hasChildren !== true && !runsByOrigin.has(child.id)
if (
folded && isLeaf && !child.current && child.team === undefined
&& (child.state === 'done' || child.state === 'error')
) {
foldCandidates.push(child)
} else {
kept.push(child)
}
}
for (const child of kept) {
out.push(child)
appendChildren(child.id)
}
if (foldCandidates.length > 0) {
out.push({
kind: 'fold',
id: `fold:${parentId}`,
parentId,
count: foldCandidates.length,
memberIds: foldCandidates.map(child => child.id),
previews: foldCandidates.slice(0, 2).map(child => child.label),
})
}
for (let index = 0; index < runNodes.length; index += 1) {
const runNode = runNodes[index]
if (runNode === undefined) continue
out.push(runNode)
const members = memberNodes[index] ?? []
// Fold settled members of the run too (same leaf rule, run as parent).
const keptMembers: TasksAgentNode[] = []
const foldedMembers: TasksAgentNode[] = []
for (const member of members) {
if (folded && !member.current && (member.state === 'done' || member.state === 'error')) {
foldedMembers.push(member)
} else {
keptMembers.push(member)
}
}
out.push(...keptMembers)
if (foldedMembers.length > 0) {
out.push({
kind: 'fold',
id: `fold:${runNode.id}`,
parentId: runNode.id,
count: foldedMembers.length,
memberIds: foldedMembers.map(member => member.id),
previews: foldedMembers.slice(0, 2).map(member => member.label),
})
}
}
}
const rootSummary = byId[rootId]
const rootTeam = teamOf(rootId)
out.push({
kind: 'agent',
id: rootId,
label: rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
? rootSummary.displayTitle
: rootId,
state: rootSummary?.running === true ? 'running' : 'done',
activity: rootSummary?.running === true ? 'running' : 'inactive',
current: rootId === currentSessionId,
...(rootTeam !== undefined ? { team: rootTeam } : {}),
})
appendChildren(rootId)
return out
}
/** The parent→child edges of a model (derived, kept out of the build). */
export interface TasksEdge {
from: string
to: string
/** Visual class: teammate links and workflow relations get the accent. */
kind: 'agent' | 'team' | 'workflow'
}
/** Derive the edge list of a built model (pre-order preserved). */
export function tasksEdges(nodes: readonly TasksNode[]): TasksEdge[] {
const byNodeId = new Map(nodes.map(node => [node.id, node]))
const edges: TasksEdge[] = []
for (const node of nodes) {
if (node.parentId === undefined || !byNodeId.has(node.parentId)) continue
const parent = byNodeId.get(node.parentId)
const kind: TasksEdge['kind'] = node.kind === 'workflow' || parent?.kind === 'workflow'
? 'workflow'
: node.kind === 'agent' && node.team?.role === 'teammate'
? 'team'
: 'agent'
edges.push({ from: node.parentId, to: node.id, kind })
}
return edges
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Unit tests for the layered graph layout (layoutTasksGraph): parent
* centering over children bands, depth rows, and live-line heights.
*/
import { describe, expect, it } from 'vitest'
import {
GRAPH_GAP_Y,
GRAPH_LIVE_H,
GRAPH_NODE_H,
GRAPH_NODE_W,
GRAPH_PAD,
layoutTasksGraph,
} from '../src/client/tasks-graph-layout.ts'
import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts'
/** An agent node. */
function agent(id: string, parentId?: string, live = false): TasksAgentNode {
return {
kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }),
label: id, state: 'running', activity: 'running', current: false,
...(live ? { live: { text: 'x' } } : {}),
}
}
describe('layoutTasksGraph', () => {
it('places the root on row 0 and children one row below', () => {
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H })
const rowY = GRAPH_PAD + GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
expect(layout.boxes.get('a')?.y).toBe(rowY)
expect(layout.boxes.get('b')?.y).toBe(rowY)
})
it('centers a parent over its children band', () => {
const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')]
const layout = layoutTasksGraph(nodes)
const a = layout.boxes.get('a')
const b = layout.boxes.get('b')
const root = layout.boxes.get('root')
expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined()
const bandCenter = ((a!.x + b!.x + GRAPH_NODE_W) / 2)
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
})
it('widens the canvas for deep/wide forests and grows live rows', () => {
const nodes: TasksNode[] = [
agent('root'), agent('a', 'root', true),
agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'),
]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('a')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H)
expect(layout.width).toBeGreaterThan(3 * GRAPH_NODE_W)
expect(layout.height).toBeGreaterThan(2 * (GRAPH_NODE_H + GRAPH_GAP_Y))
})
it('gives every node a box even with a defensive orphan', () => {
const nodes: TasksNode[] = [agent('root'), agent('ghost', 'missing')]
const layout = layoutTasksGraph(nodes)
expect(layout.boxes.get('root')).toBeDefined()
expect(layout.boxes.get('ghost')).toBeDefined()
})
})
+193
View File
@@ -0,0 +1,193 @@
/**
* Unit tests for the unified Tasks view model (buildTasksModel / tasksEdges):
* catalog walking, workflow run attachment + member re-parenting, team
* enrichment, and the per-parent settled-leaf fold.
*/
import { describe, expect, it } from 'vitest'
import {
buildTasksModel,
tasksEdges,
type TasksAgentNode,
type TasksModelInput,
type TasksNode,
} from '../src/client/tasks-model.ts'
import type {
SidebarSessionSummary,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarTeamMemberView,
} from '../src/context-types.ts'
import type { WorkflowRunView } from '../src/workflow-runs.ts'
/** A summary row. */
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: id, ...over }
}
/** A catalog child row. */
function child(id: string, over: Partial<SidebarSubagentChildEntry> = {}): SidebarSubagentChildEntry {
return { kind: 'child', id, activity: 'running', hasChildren: false, mode: 'one-shot', ...over }
}
/** A ready catalog. */
function catalog(entries: SidebarSubagentCatalog['entries']): SidebarSubagentCatalog {
return { entries, parentAvailable: true, state: 'ready', error: null }
}
/** A workflow run view. */
function run(over: Partial<WorkflowRunView> = {}): WorkflowRunView {
return {
runId: 'run-1', name: 'audit', originSessionId: 'root', status: 'running',
phases: [], startedSeq: 1, startedAt: 0, ...over,
}
}
/** A team member view. */
function member(over: Partial<SidebarTeamMemberView> = {}): SidebarTeamMemberView {
return { id: 'x', name: 'n', role: 'teammate', status: 'running', diagnostics: [], ...over }
}
/** A base input; override per test. */
function input(over: Partial<TasksModelInput> = {}): TasksModelInput {
return {
byId: { root: summary('root', { displayTitle: 'Root', running: true }) },
catalogs: {},
rootId: 'root',
currentSessionId: 'root',
live: {},
runs: [],
teamMembers: [],
folded: true,
...over,
}
}
/** All agent nodes of a model. */
function agents(nodes: readonly TasksNode[]): TasksAgentNode[] {
return nodes.filter((node): node is TasksAgentNode => node.kind === 'agent')
}
describe('buildTasksModel', () => {
it('walks the catalogs in pre-order and marks the current session', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a'), child('b', { activity: 'inactive' })]),
a: catalog([child('a1')]),
},
byId: {
root: summary('root', { displayTitle: 'Root', running: true }),
a: summary('a'), b: summary('b'), a1: summary('a1'),
},
currentSessionId: 'a1',
folded: false,
}))
expect(model.map(node => node.id)).toEqual(['root', 'a', 'a1', 'b'])
expect(agents(model).find(node => node.id === 'a1')?.current).toBe(true)
expect(agents(model).find(node => node.id === 'a1')?.childAddress).toEqual({
parentSessionId: 'a', childSessionId: 'a1', mode: 'one-shot',
})
})
it('excludes Side Chat threads from the model', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('a'), child('side', { label: 'Side: 闲聊' })]) },
byId: { root: summary('root'), a: summary('a'), side: summary('side') },
}))
expect(model.map(node => node.id)).toEqual(['root', 'a'])
})
it('re-parents run members under the run node and synthesizes missing ones', () => {
const audit = run({
originSessionId: 'root',
phases: [{
title: '侦察',
members: [
{ seq: 1, label: 'existing', phase: '侦察', childId: 'm1', outcome: 'completed' },
{ seq: 2, label: 'synthesized', phase: '侦察', childId: 'm2' },
],
}],
})
const model = buildTasksModel(input({
catalogs: { root: catalog([child('m1', { activity: 'inactive' })]) },
byId: { root: summary('root'), m1: summary('m1', { displayTitle: 'M1' }) },
runs: [audit],
folded: false,
}))
expect(model.map(node => node.id)).toEqual(['root', 'run:run-1', 'm1', 'm2'])
const synthesized = agents(model).find(node => node.id === 'm2')
expect(synthesized).toMatchObject({ parentId: 'run:run-1', label: 'synthesized', synthesized: true, state: 'running' })
const existing = agents(model).find(node => node.id === 'm1')
expect(existing?.parentId).toBe('run:run-1')
})
it('enriches teammates and the lead from the team view', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('w', { label: 'writer' })]) },
byId: { root: summary('root'), w: summary('w') },
teamMembers: [
member({ id: 'root', name: 'lead', role: 'lead' }),
member({ id: 'w', name: 'writer', model: 'glm-5.3', status: 'idle' }),
],
}))
expect(agents(model).find(node => node.id === 'root')?.team?.role).toBe('lead')
const w = agents(model).find(node => node.id === 'w')
expect(w?.team).toMatchObject({ role: 'teammate', name: 'writer', model: 'glm-5.3' })
})
it('folds settled leaves per parent and keeps running/teammate/current rows', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([
child('done-1', { activity: 'inactive' }),
child('done-2', { activity: 'inactive' }),
child('live', { activity: 'running' }),
child('mate', { activity: 'inactive' }),
child('parent', { activity: 'inactive', hasChildren: true }),
]),
parent: catalog([child('grandchild')]),
},
byId: {
root: summary('root'),
'done-1': summary('done-1'), 'done-2': summary('done-2'),
live: summary('live'), mate: summary('mate'),
parent: summary('parent'), grandchild: summary('grandchild'),
},
teamMembers: [member({ id: 'mate', status: 'idle' })],
}))
expect(model.map(node => node.id)).toEqual([
'root', 'live', 'mate', 'parent', 'grandchild', 'fold:root',
])
const fold = model.find(node => node.kind === 'fold')
expect(fold).toMatchObject({ parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'] })
})
it('unfolds everything when folded is false', () => {
const base = input({
catalogs: { root: catalog([child('done-1', { activity: 'inactive' })]) },
byId: { root: summary('root'), 'done-1': summary('done-1') },
folded: false,
})
expect(buildTasksModel(base).map(node => node.id)).toEqual(['root', 'done-1'])
})
})
describe('tasksEdges', () => {
it('classifies team/workflow/agent edges', () => {
const audit = run({
originSessionId: 'root',
phases: [{ members: [{ seq: 1, label: 'm', childId: 'm1' }] }],
})
const model = buildTasksModel(input({
catalogs: { root: catalog([child('w'), child('m1')]) },
byId: { root: summary('root'), w: summary('w'), m1: summary('m1') },
runs: [audit],
teamMembers: [member({ id: 'w' })],
folded: false,
}))
const edges = tasksEdges(model)
const kindOf = (to: string) => edges.find(edge => edge.to === to)?.kind
expect(kindOf('w')).toBe('team')
expect(kindOf('run:run-1')).toBe('workflow')
expect(kindOf('m1')).toBe('workflow')
})
})