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:
1 parent
c445495f8b
commit
fdc4520080
5 files changed
+741
-1
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user