Files
apes-sidebar/tests/team-projection.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

121 lines
4.8 KiB
TypeScript

/**
* Unit tests for the `agentTeam` projection adapter: reading the Lead
* Session's board off the client snapshot, and overlaying runtime activity
* onto the durable roster.
*
* The shape under test is DSH 0.1.7's `TeamProjection` (see the experimental
* package's `lib/types/types.d.ts`): `{ members: {id,name,role,phase,error?},
* tasks: TeamTaskView[], failure? }`. There is no `available` flag and no
* `model` field — a projection that is absent means "this session leads no
* team" (or the deployment never mounted the layer), which is the same silent
* degradation either way.
*/
import { describe, expect, it } from 'vitest'
import { teamMembersOf, teamProjectionOf } from '../src/client/team-projection.ts'
import type {
SidebarChildLiveView,
SidebarSessionList,
SidebarSessionSummary,
SidebarTeamMemberProjection,
SidebarTeamProjection,
} from '../src/context-types.ts'
/** One durable roster row. */
function member(over: Partial<SidebarTeamMemberProjection> = {}): SidebarTeamMemberProjection {
return { id: 'w', name: 'writer', role: 'teammate', phase: 'active', ...over }
}
/** One Lead Session projection. */
function team(over: Partial<SidebarTeamProjection> = {}): SidebarTeamProjection {
return { members: [member()], tasks: [], ...over }
}
/** The snapshot's projection map with one session's team installed. */
function projections(
rootId: string,
teamValue: SidebarTeamProjection,
): SidebarSessionList['projectionsBySession'] {
return { [rootId]: { values: { agentTeam: teamValue }, state: 'ready', error: null } }
}
/** A summary row. */
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: id, ...over }
}
describe('teamProjectionOf', () => {
it('reads the root session own projection', () => {
const value = team()
expect(teamProjectionOf(projections('root', value), 'root')).toBe(value)
})
it('answers undefined without a root, without a snapshot, or without a team', () => {
expect(teamProjectionOf(projections('root', team()), undefined)).toBeUndefined()
expect(teamProjectionOf(undefined, 'root')).toBeUndefined()
// A session the projection store knows, but which leads no team (the
// deployment without the experimental layer, or a plain subagent tree).
const other: SidebarSessionList['projectionsBySession'] = {
root: { values: {}, state: 'ready', error: null },
}
expect(teamProjectionOf(other, 'root')).toBeUndefined()
expect(teamProjectionOf(projections('root', team()), 'someone-else')).toBeUndefined()
})
})
describe('teamMembersOf', () => {
const noLive: Record<string, SidebarChildLiveView | undefined> = {}
it('answers an empty roster without a team', () => {
expect(teamMembersOf(undefined, noLive, {})).toEqual([])
})
it('carries the durable phase and the member identity through', () => {
const rows = teamMembersOf(
team({ members: [member({ id: 'lead', name: 'lead', role: 'lead' }), member({ id: 'w' })] }),
noLive,
{},
)
expect(rows).toEqual([
{ id: 'lead', name: 'lead', role: 'lead', phase: 'active', status: 'idle', diagnostics: [] },
{ id: 'w', name: 'writer', role: 'teammate', phase: 'active', status: 'idle', diagnostics: [] },
])
})
it('reads an active member activity from the live channel', () => {
const live: Record<string, SidebarChildLiveView | undefined> = { w: { running: true } }
expect(teamMembersOf(team(), live, {})[0]?.status).toBe('running')
})
it('falls back to the session list running flag (the Lead is no live child)', () => {
const byId = { lead: summary('lead', { running: true }) }
const rows = teamMembersOf(
team({ members: [member({ id: 'lead', name: 'lead', role: 'lead' })] }),
noLive,
byId,
)
expect(rows[0]?.status).toBe('running')
})
it('never claims a running turn while the durable phase disagrees', () => {
const live: Record<string, SidebarChildLiveView | undefined> = { w: { running: true } }
const provisioning = teamMembersOf(team({ members: [member({ phase: 'provisioning' })] }), live, {})
expect(provisioning[0]?.status).toBe('provisioning')
const failed = teamMembersOf(team({ members: [member({ phase: 'failed' })] }), live, {})
expect(failed[0]?.status).toBe('failed')
})
it('exposes the durable member error as the diagnostics list', () => {
const rows = teamMembersOf(
team({ members: [member({ phase: 'failed', error: 'spawn rejected' })] }),
noLive,
{},
)
expect(rows[0]?.diagnostics).toEqual(['spawn rejected'])
})
it('treats a member the live channel has not mentioned as idle', () => {
const live: Record<string, SidebarChildLiveView | undefined> = { other: { running: true } }
expect(teamMembersOf(team(), live, {})[0]?.status).toBe('idle')
})
})