Files
apes-sidebar/tests/team-routes.spec.ts
T
Menghuan1918 c445495f8b feat(host): add workflows.list and teams.* sidebar routes
- workflows.list folds tool-workflow/* session events (the same four types
  the official workflow-run panel folds) across the whole session tree,
  merging the stored log with a live session/event mirror deduped by seq;
  absence of events yields an empty list, never an error.
- teams.view / teams.taskCreate / teams.taskUpdate ride the experimental
  agentTeams service's Remote vocabulary with structural degradation:
  layer absent -> {available:false}; root leads no team -> {team:null};
  CAS conflicts pass through the service's own result union.
- wire gains the team-error code; context-types gains structural mirrors
  of the team view/request vocabulary (no experimental package import).
- 29 unit tests across fold, route merging/degradation, and CAS shaping.
2026-09-27 22:18:14 +08:00

181 lines
7.9 KiB
TypeScript

/**
* Host route tests for the Agent Teams API ('teams.view' / 'teams.taskCreate'
* / 'teams.taskUpdate'): structural degradation (layer absent → available:
* false; root leads no team → team: null), passthrough of the service's own
* Remote vocabulary, CAS conflict unions returned untouched, and payload
* validation. The plugin never imports the experimental package — the fake
* service here is a structural double.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildTeamsApi } from '../src/team-routes.ts'
import type {
Context,
SidebarTeamMemberView,
SidebarTeamTaskView,
} from '../src/context-types.ts'
/** One member row. */
function member(over: Partial<SidebarTeamMemberView> = {}): SidebarTeamMemberView {
return {
id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [], ...over,
}
}
/** 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) }
}
describe('teams.view route', () => {
it('reports available:false when the experimental layer is absent', async () => {
const api = buildTeamsApi(ctxWith(undefined, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: false })
})
it('reports team:null when the root agent is not live', async () => {
const teams = { tryMembership: vi.fn(), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, { get: () => undefined }))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
expect(teams.remoteView).not.toHaveBeenCalled()
})
it('reports team:null when tryMembership misses', async () => {
const teams = { tryMembership: vi.fn(async () => undefined), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
expect(teams.remoteView).not.toHaveBeenCalled()
})
it('reports team:null when tryMembership throws (stale member)', async () => {
const teams = { tryMembership: vi.fn(async () => { throw new Error('stale') }), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
})
it('returns the service view on a membership hit', async () => {
const view = { members: [member()], tasks: [task()] }
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteView: vi.fn(async () => view),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: view })
})
it('maps a remoteView rejection to a 400 team-error', async () => {
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteView: vi.fn(async () => { throw new Error('replay failed') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400 }),
)
})
})
describe('teams.taskCreate route', () => {
it('passes the shaped request through and returns the union', async () => {
const created = task({ id: 'task-9', subject: '新任务' })
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteCreateTask: vi.fn(async () => ({ ok: true, value: 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.remoteCreateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ subject: '新任务', description: '详', blockedBy: ['task-1'] },
)
})
it('degrades to 503 on a mutation when the 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 leads no team', async () => {
const teams = { tryMembership: vi.fn(async () => undefined) }
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', async () => {
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })) }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'bad-request' }),
)
})
})
describe('teams.taskUpdate route', () => {
it('passes the CAS request through, conflict union untouched', async () => {
const conflict = { ok: false, error: { code: 'team-task-conflict', message: 'stale revision' } }
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteUpdateTask: vi.fn(async () => conflict),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 3, action: 'complete',
})
expect(result).toEqual(conflict)
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 3, action: 'complete' },
)
})
it('validates taskId / expectedRevision / action', async () => {
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })), remoteUpdateTask: 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.remoteUpdateTask).not.toHaveBeenCalled()
})
it('forwards optional edit fields when present', async () => {
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteUpdateTask: vi.fn(async () => ({ ok: true, value: task() })),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer',
})
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer' },
)
})
})