Files
apes-sidebar/tests/tasks-model.spec.ts
T
Menghuan1918 d8bc6566df feat(tasks): 适配 DSH 0.1.7 的 Agent Teams(读 projection + 写路由对接真实服务)
团队功能在 0.1.7 上整条失效:team-routes.ts 当初镜像的是 0.1.6 的 Remote 词汇
(remoteView / remoteCreateTask / remoteUpdateTask),而 0.1.7 把这三个方法删掉了,
读路径改到 Lead Session 的 `agentTeam` Session projection。真机 3080 复现:
teams.view 对带团队的会话返回 400 "membership.svc.remoteView is not a function",
于是 TeamBoard 从不渲染、节点富化与任务窗连带失效。

- 读:删掉 teams.view 与 5s 轮询,改读 SessionListState.projectionsBySession[root]
  .values.agentTeam(与 subagentCatalog 同源,推送式);新增 client/team-projection.ts
  做纯适配器,成员运行状态由 subagents.live 的 running 叠加、Lead 回退 session list
  (官方 README 的 "overlays activity from Session status")。projection 缺席 =
  这里没有团队,与旧的 {available:false}/{team:null} 合并成同一静默降级。
- 写:taskCreate/taskUpdate 改调 0.1.7 的 createTask/updateTask(返回已提交的 view,
  拒绝改为抛 TeamError),TEAM_TASK_STALE_REVISION → 409 team-conflict,其余 → 400
  team-error(wire 新增 team-conflict)。api.ts 的 writeTask 把失败信封还原成
  {ok:false,code,message} —— 原先窗口永远走不到该分支(readEnvelope 是抛)。
- 任务窗:外壳换成上一轮的 FloatingWindow(可拖拽/可拉伸/只在关闭或 Esc 时消失),
  动作行下沉到 shell 的 footer,长描述不再把动作推出视野。
- 真机抓到的两个单测抓不到的问题:
  1. 队列中任务的「完成」被宿主状态机拒绝(pending → claim → in_progress →
     complete → completed → reopen)→ 动作行跟随状态,pending 出「认领」;
  2. 认领后 Tag 变「阻塞」—— ready 的语义只是「pending 且无未完成 blocker」,
     对所有非 pending 任务都是 false → 抽出唯一规则 taskBlocked/taskStatusLabel/
     taskTone/taskDotState,四处展示点全部改走它。
- i18n:新增 teamTaskClaim / taskWindowCreate / taskWindowDetail /
  tasksStateProvisioning 词条 ×20 词典。

验证:typecheck / lint 通过;pnpm test 1276 passed / 9 skipped;
pnpm test:mount 7/7(真实 DSH 0.1.7-rc.2 挂载 + tab 全扫无崩溃)。
真机 3080(0.1.7-rc.1 + Agent Teams 实验层):看板从 projection 渲染、
队列任务「认领」→「完成」全链路写通(负责人 lead / 进行中 / 已完成)、
外部点击不关窗、Escape 关窗,截图见 docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md。

(cherry picked from commit dbc524e0b350c5d3564843f4776cd15d797506d8)
2026-09-27 22:19:05 +08:00

299 lines
12 KiB
TypeScript

/**
* 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,
SidebarSubagentCatalogEntry,
} from '../src/context-types.ts'
import type { TeamMemberRow } from '../src/client/team-projection.ts'
import { taskBlocked, taskDotState, taskStatusLabel, taskTone } from '../src/client/tasks-shared.tsx'
import type { SubagentCatalogView } from '../src/client/subagent-catalog.ts'
import type { SidebarChildLiveView } 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 }
}
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
function child(id: string, over: Partial<SidebarSubagentCatalogEntry> = {}): SidebarSubagentCatalogEntry {
return { id, createdAt: 0, mode: 'one-shot', ...over }
}
/** A loaded catalog view (what the projection folds into). */
function catalog(entries: SidebarSubagentCatalogEntry[]): SubagentCatalogView {
return { entries, state: 'ready', error: null }
}
/** The live channel's fold: only the RUNNING children appear (DSH 0.1.7). */
/** The live map of a set of RUNNING sessions (the new live-view shape). */
function running(...ids: string[]): Record<string, SidebarChildLiveView> {
return Object.fromEntries(ids.map(id => [id, { running: true, text: `${id} is working` }]))
}
/** 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 derived roster row (the shape ./team-projection.ts hands the model). */
function member(over: Partial<TeamMemberRow> = {}): TeamMemberRow {
return { id: 'x', name: 'n', role: 'teammate', phase: 'active', 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')]),
a: catalog([child('a1')]),
},
byId: {
root: summary('root', { displayTitle: 'Root', running: true }),
a: summary('a'), b: summary('b'), a1: summary('a1'),
},
currentSessionId: 'a1',
live: running('a', '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',
})
// Activity comes from the live channel, and `b` (absent there) is settled.
expect(agents(model).find(node => node.id === 'a')?.state).toBe('running')
expect(agents(model).find(node => node.id === 'b')?.state).toBe('done')
})
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')]) },
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 roster projection', () => {
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', phase: 'active', 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', phase: 'active', status: 'idle' })
})
it('gives a provisioning teammate the running node state', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('w')]) },
byId: { root: summary('root'), w: summary('w') },
teamMembers: [member({ id: 'w', phase: 'provisioning', status: 'provisioning' })],
}))
expect(agents(model).find(node => node.id === 'w')?.state).toBe('running')
})
it('folds settled leaves per parent and keeps running/teammate/current rows', () => {
const model = buildTasksModel(input({
catalogs: {
root: catalog([
child('done-1'), child('done-2'), child('live'), child('mate'), child('parent'),
]),
// A KNOWN LEAF is a child whose OWN catalog loaded empty (0.1.7 rows
// carry no `hasChildren`): only those two are fold candidates.
'done-1': catalog([]),
'done-2': catalog([]),
'live': catalog([]),
'mate': catalog([]),
// `parent` has a child of its own, so it keeps its whole branch.
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'),
},
live: running('live'),
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('keeps a child whose own catalog is unknown (never folds on missing data)', () => {
const model = buildTasksModel(input({
catalogs: { root: catalog([child('opaque')]) },
byId: { root: summary('root'), opaque: summary('opaque') },
}))
expect(model.map(node => node.id)).toEqual(['root', 'opaque'])
})
it('unfolds everything when folded is false', () => {
const base = input({
catalogs: { root: catalog([child('done-1')]) },
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')
})
})
describe('buildTasksModel: duplicate workflow members', () => {
it('never synthesizes a second node for a member another parent already lists', () => {
// `m1` is a real child of `a`, but a run started by `root` names it as a
// member — the node must stay single (React keys / edge map depend on it)
// and the run simply does not duplicate it on the canvas.
const audit = run({
originSessionId: 'root',
phases: [{ title: '侦察', members: [{ seq: 1, label: 'm1', childId: 'm1', outcome: 'completed' }] }],
})
const model = buildTasksModel(input({
catalogs: {
root: catalog([child('a')]),
a: catalog([child('m1')]),
},
byId: { root: summary('root'), a: summary('a'), m1: summary('m1') },
runs: [audit],
folded: false,
}))
const ids = model.map(node => node.id)
expect(ids.filter(id => id === 'm1')).toHaveLength(1)
expect(new Set(ids).size).toBe(ids.length)
// The real node keeps its catalog parent.
expect(model.find(node => node.id === 'm1')?.parentId).toBe('a')
})
it('still synthesizes a member no catalog knows', () => {
const audit = run({
originSessionId: 'root',
phases: [{ title: '侦察', members: [{ seq: 1, label: 'ghost member', childId: 'ghost' }] }],
})
const model = buildTasksModel(input({
catalogs: { root: catalog([]) },
byId: { root: summary('root') },
runs: [audit],
folded: false,
}))
const ghost = model.find(node => node.id === 'ghost')
expect(ghost).toMatchObject({ parentId: 'run:run-1', synthesized: true })
})
})
describe('shared task status rule (taskBlocked / taskStatusLabel / taskTone)', () => {
const facts = (status: 'pending' | 'in_progress' | 'completed', ready: boolean) => ({ status, ready })
it('calls a queued task with an open blocker blocked', () => {
expect(taskBlocked(facts('pending', false))).toBe(true)
expect(taskStatusLabel(facts('pending', false))).toBe('Blocked')
expect(taskTone(facts('pending', false))).toBe('warning')
})
it('never calls a CLAIMED task blocked, even though the service reports ready:false', () => {
// The board's `ready` flag means "a pending task clear to claim": it is
// false for every non-pending task, which is exactly how a task being
// actively worked on got labelled 阻塞 on the real board.
expect(taskBlocked(facts('in_progress', false))).toBe(false)
expect(taskStatusLabel(facts('in_progress', false))).toBe('In progress')
expect(taskTone(facts('in_progress', false))).toBe('info')
})
it('never calls a finished task blocked', () => {
expect(taskBlocked(facts('completed', false))).toBe(false)
expect(taskStatusLabel(facts('completed', false))).toBe('Completed')
expect(taskTone(facts('completed', false))).toBe('success')
})
it('reads a claimable queued task as pending, not blocked', () => {
expect(taskStatusLabel(facts('pending', true))).toBe('Pending')
expect(taskTone(facts('pending', true))).toBe('info')
expect(taskDotState(facts('pending', true))).toBe('ongoing')
})
})