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

230 lines
10 KiB
TypeScript

/**
* Host route tests for the Agent Teams WRITE API ('teams.taskCreate' /
* 'teams.taskUpdate'): structural degradation (layer absent → 503, root not
* live / leads no team → 404), the exact request shape the 0.1.7 service
* demands, and the rejection→wire-code mapping that keeps a stale CAS
* revision distinct from every other failure.
*
* There is deliberately no `teams.view` coverage left: DSH 0.1.7 moved the
* board's read path onto the Lead Session's `agentTeam` projection, which the
* client reads off its own snapshot (tests/team-projection.spec.ts). The 0.1.6
* `remoteView` vocabulary this suite used to lock down no longer exists in the
* service — that is the drift this file now guards against.
*
* The plugin never imports the experimental package; the fake service here is
* a structural double whose method names and rejection shapes mirror
* `@deepseek-ai/dsh-experimental-agent-team@0.1.7-rc.1`'s `TeamService`.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildTeamsApi } from '../src/team-routes.ts'
import type { Context, SidebarTeamTaskView } from '../src/context-types.ts'
/** One task row. */
function task(over: Partial<SidebarTeamTaskView> = {}): SidebarTeamTaskView {
return {
id: 'task-1', revision: 1, subject: 's', description: 'd', status: 'pending',
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], ...over,
}
}
/** A context serving the given agentTeams/agents faces. */
function ctxWith(teams: unknown, agents: unknown): Context {
return {
get: (key: string) => (key === 'agentTeams' ? teams : key === 'agents' ? agents : undefined),
} as unknown as Context
}
/** An agents face resolving one live root agent. */
function agentsWithRoot(): { get(id: string): { id: string } | undefined } {
return { get: (id: string) => (id === 'root' ? { id: 'root' } : undefined) }
}
/** The service's typed rejection (a `TeamError` / `HarnessError` subclass). */
function teamError(code: string, message: string): Error & { code: string } {
return Object.assign(new Error(message), { code, name: 'TeamError' })
}
describe('teams.taskCreate route', () => {
it('passes the shaped request through and returns the committed view', async () => {
const created = task({ id: 'task-9', subject: '新任务' })
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
createTask: vi.fn(async () => created),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskCreate({
rootSessionId: 'root', subject: '新任务', description: '详', blockedBy: ['task-1'],
})
expect(result).toEqual({ ok: true, value: created })
expect(teams.createTask).toHaveBeenCalledWith(
{ id: 'root' },
{ subject: '新任务', description: '详', blockedBy: ['task-1'] },
)
})
it('degrades to 503 when the experimental layer is absent', async () => {
const api = buildTeamsApi(ctxWith(undefined, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 503 }),
)
})
it('degrades to 404 when the root agent is not live in this process', async () => {
const teams = { tryMembership: vi.fn(() => ({ role: 'lead' })), createTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, { get: () => undefined }))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
expect(teams.createTask).not.toHaveBeenCalled()
})
it('degrades to 404 when tryMembership misses (root leads no team)', async () => {
const teams = { tryMembership: vi.fn(() => undefined), createTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
})
it('maps a throwing tryMembership to 404 (stale member identity)', async () => {
const teams = {
tryMembership: vi.fn(() => { throw new Error('stale') }),
createTask: vi.fn(),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
})
it('rejects a missing subject as bad-request before calling the service', async () => {
const teams = { tryMembership: vi.fn(() => ({ role: 'lead' })), createTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'bad-request' }),
)
expect(teams.createTask).not.toHaveBeenCalled()
})
it('maps a service rejection to a 400 team-error', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
createTask: vi.fn(async () => { throw teamError('TEAM_TASK_LIMIT', 'task limit reached') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400, message: 'task limit reached' }),
)
})
})
describe('teams.taskUpdate route', () => {
it('passes the CAS request through and returns the committed view', async () => {
const done = task({ status: 'completed', revision: 2 })
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => done),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 1, action: 'complete',
})
expect(result).toEqual({ ok: true, value: done })
expect(teams.updateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 1, action: 'complete' },
)
})
it('maps a stale revision to 409 team-conflict (0.1.7 throws instead of returning)', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => {
throw teamError('TEAM_TASK_STALE_REVISION', 'stale team task "task-1" revision 1; current revision is 2')
}),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 1, action: 'complete',
})).rejects.toThrowError(expect.objectContaining({ code: 'team-conflict', status: 409 }))
})
it('keeps every other rejection on team-error, never on team-conflict', async () => {
for (const code of ['TEAM_TASK_NOT_FOUND', 'TEAM_NOT_MEMBER', 'TEAM_DISPOSED']) {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => { throw teamError(code, 'nope') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({
rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'complete',
})).rejects.toThrowError(expect.objectContaining({ code: 'team-error', status: 400 }))
}
})
it('reports an untagged rejection as a 400 with its own message', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => { throw new Error('board exploded') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({
rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'complete',
})).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400, message: 'board exploded' }),
)
})
it('validates taskId / expectedRevision / action before calling the service', async () => {
const teams = { tryMembership: vi.fn(() => ({ role: 'lead' })), updateTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({ rootSessionId: 'root', expectedRevision: 1, action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: -1, action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'nuke' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
expect(teams.updateTask).not.toHaveBeenCalled()
})
it('forwards optional edit fields only when present', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => task()),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer',
})
expect(teams.updateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer' },
)
teams.updateTask.mockClear()
await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 2, action: 'edit',
subject: 'S', description: 'D', blockedBy: ['task-2'], writeScopes: ['src/'],
})
expect(teams.updateTask).toHaveBeenCalledWith(
{ id: 'root' },
{
taskId: 'task-1', expectedRevision: 2, action: 'edit',
subject: 'S', description: 'D', blockedBy: ['task-2'], writeScopes: ['src/'],
},
)
})
it('never leaks the 0.1.6 remote vocabulary into the service call', async () => {
const teams = {
tryMembership: vi.fn(() => ({ role: 'lead' })),
updateTask: vi.fn(async () => task()),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'claim' })
expect(Object.keys(teams)).not.toContain('remoteUpdateTask')
expect(Object.keys(teams)).not.toContain('remoteView')
})
})