团队功能在 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)
230 lines
10 KiB
TypeScript
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')
|
|
})
|
|
})
|