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)
This commit is contained in:
@@ -161,9 +161,6 @@ beforeEach(() => {
|
||||
if (method === 'workflows.list') {
|
||||
return jsonResponse({ ok: true, value: { runs: [] } })
|
||||
}
|
||||
if (method === 'teams.view') {
|
||||
return jsonResponse({ ok: true, value: { available: false } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
import type {
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalogEntry,
|
||||
SidebarTeamMemberView,
|
||||
} 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'
|
||||
@@ -49,9 +50,9 @@ function run(over: Partial<WorkflowRunView> = {}): WorkflowRunView {
|
||||
}
|
||||
}
|
||||
|
||||
/** A team member view. */
|
||||
function member(over: Partial<SidebarTeamMemberView> = {}): SidebarTeamMemberView {
|
||||
return { id: 'x', name: 'n', role: 'teammate', status: 'running', diagnostics: [], ...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. */
|
||||
@@ -131,18 +132,27 @@ describe('buildTasksModel', () => {
|
||||
expect(existing?.parentId).toBe('run:run-1')
|
||||
})
|
||||
|
||||
it('enriches teammates and the lead from the team view', () => {
|
||||
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', model: 'glm-5.3', status: 'idle' }),
|
||||
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', model: 'glm-5.3' })
|
||||
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', () => {
|
||||
@@ -255,3 +265,34 @@ describe('buildTasksModel: duplicate workflow members', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
+188
-79
@@ -20,6 +20,9 @@ import type {
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalogEntry,
|
||||
SidebarTeamMemberProjection,
|
||||
SidebarTeamProjection,
|
||||
SidebarTeamTaskView,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
@@ -102,18 +105,29 @@ function jsonResponse(value: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => value } as unknown as Response
|
||||
}
|
||||
|
||||
let teamPayload: unknown = { available: false }
|
||||
/** A failed `/sidebar/api` envelope (the wire shape `readEnvelope` parses). */
|
||||
function jsonError(status: number, code: string, message: string): Response {
|
||||
return {
|
||||
ok: false, status, json: async () => ({ ok: false, error: { code, message } }),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
/** The Lead Session's `agentTeam` projection the board reads (undefined = none). */
|
||||
let teamProjection: SidebarTeamProjection | undefined
|
||||
const teamMutations: Array<{ method: string; body: Record<string, unknown> }> = []
|
||||
const fetchedMethods: string[] = []
|
||||
/** The `subagents.live` payload the stub answers with (session id → live view). */
|
||||
let livePayload: Record<string, unknown> = {}
|
||||
/** The folded workflow runs the stub answers `workflows.list` with. */
|
||||
let runsPayload: unknown[] = []
|
||||
/** A write rejection the stub answers `teams.taskUpdate` with (undefined = ok). */
|
||||
let writeRejection: { status: number; code: string; message: string } | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
teamPayload = { available: false }
|
||||
teamProjection = undefined
|
||||
livePayload = {}
|
||||
runsPayload = []
|
||||
writeRejection = undefined
|
||||
teamMutations.length = 0
|
||||
fetchedMethods.length = 0
|
||||
jobsFake = makeJobsService()
|
||||
@@ -128,17 +142,17 @@ beforeEach(() => {
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
|
||||
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: livePayload } })
|
||||
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: runsPayload } })
|
||||
if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
|
||||
if (method === 'teams.taskCreate' || method === 'teams.taskUpdate') {
|
||||
teamMutations.push({ method: method ?? '', body })
|
||||
if (writeRejection !== undefined) {
|
||||
return jsonError(writeRejection.status, writeRejection.code, writeRejection.message)
|
||||
}
|
||||
// 0.1.7 hands back the COMMITTED view (no result union any more).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
ok: true,
|
||||
value: {
|
||||
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
|
||||
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
},
|
||||
id: 't9', revision: 9, subject: body.subject ?? 'x', description: body.description ?? '',
|
||||
status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -171,7 +185,11 @@ function snapshotWithChildren(count: number): SidebarSessionList {
|
||||
entries.push({ id, createdAt: 1_000 + index, mode: 'one-shot', label: `子代理 ${index}` })
|
||||
projectionsBySession[id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
|
||||
}
|
||||
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
|
||||
projectionsBySession.root = {
|
||||
values: { subagentCatalog: entries, ...(teamProjection === undefined ? {} : { agentTeam: teamProjection }) },
|
||||
state: 'ready',
|
||||
error: null,
|
||||
}
|
||||
return { byId, projectionsBySession }
|
||||
}
|
||||
|
||||
@@ -193,7 +211,11 @@ function snapshotWithRun(): SidebarSessionList {
|
||||
byId[entry.id] = { id: entry.id, displayTitle: entry.id, origin: 'subagent', parentId: 'root', running: false }
|
||||
projectionsBySession[entry.id] = { values: { subagentCatalog: [] }, state: 'ready', error: null }
|
||||
}
|
||||
projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null }
|
||||
projectionsBySession.root = {
|
||||
values: { subagentCatalog: entries, ...(teamProjection === undefined ? {} : { agentTeam: teamProjection }) },
|
||||
state: 'ready',
|
||||
error: null,
|
||||
}
|
||||
return { byId, projectionsBySession }
|
||||
}
|
||||
|
||||
@@ -349,6 +371,36 @@ describe('Tasks page interactions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The Lead Session's projection for one test run: members + tasks, or absent
|
||||
* (`withTeam(null)`) when the tree leads no team. A task omitted from `tasks`
|
||||
* is not on the board at all.
|
||||
*/
|
||||
function withTeam(input: {
|
||||
members?: SidebarTeamMemberProjection[]
|
||||
tasks?: SidebarTeamTaskView[]
|
||||
} | null = {}): void {
|
||||
if (input === null) {
|
||||
teamProjection = undefined
|
||||
return
|
||||
}
|
||||
teamProjection = {
|
||||
members: input.members ?? [
|
||||
{ id: 'root', name: 'lead', role: 'lead', phase: 'active' },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', phase: 'active' },
|
||||
],
|
||||
tasks: input.tasks ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/** One shared task row. */
|
||||
function teamTask(over: Partial<SidebarTeamTaskView> = {}): SidebarTeamTaskView {
|
||||
return {
|
||||
id: 't1', revision: 1, subject: '收窄卡片', description: '细节', status: 'in_progress',
|
||||
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], ...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Tasks page graph interactions and team board', () => {
|
||||
it('activates a graph node after a background pointerdown (no click theft)', async () => {
|
||||
const navigation: NavigationSpy = { opened: [] }
|
||||
@@ -395,59 +447,50 @@ describe('Tasks page graph interactions and team board', () => {
|
||||
})
|
||||
|
||||
it('shows the team board without any click when the root leads a team', async () => {
|
||||
teamPayload = {
|
||||
available: true,
|
||||
team: {
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'idle', model: 'glm-5.3', diagnostics: [] },
|
||||
],
|
||||
tasks: [{
|
||||
id: 't1', revision: 1, subject: '收窄卡片', description: '', status: 'in_progress',
|
||||
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
}],
|
||||
},
|
||||
}
|
||||
withTeam({ tasks: [teamTask()] })
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
// Visible by default: header, members and the task row (no chip to click).
|
||||
expect(container.querySelector('[data-team-board]')).not.toBeNull()
|
||||
expect(container.textContent).toContain('团队任务板')
|
||||
expect(container.textContent).toContain('lead')
|
||||
expect(container.textContent).toContain('writer')
|
||||
expect(container.textContent).toContain('收窄卡片')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('draws no team surface at all when the root leads no team', async () => {
|
||||
withTeam(null)
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(container.querySelector('[data-team-board]')).toBeNull()
|
||||
expect(container.textContent).not.toContain('团队任务板')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tasks page: owned tasks, host-primitive controls, draggable output', () => {
|
||||
/** A team payload whose only task is owned by the child agent. */
|
||||
function teamWithOwnedTask(): unknown {
|
||||
return {
|
||||
available: true,
|
||||
team: {
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: 't1', revision: 1, subject: '收窄卡片与图标化', description: '细节', status: 'in_progress',
|
||||
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
},
|
||||
{
|
||||
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
|
||||
ownerName: 'writer', blockedBy: ['t1'], writeScopes: [], ready: false, writeScopeWarnings: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
/** A board whose two tasks are both owned by the child agent. */
|
||||
function teamWithOwnedTask(): void {
|
||||
withTeam({
|
||||
tasks: [
|
||||
teamTask({ subject: '收窄卡片与图标化', ownerName: 'writer' }),
|
||||
teamTask({
|
||||
id: 't2', revision: 2, subject: '补点击回归', description: '', status: 'pending',
|
||||
ownerName: 'writer', blockedBy: ['t1'], ready: false,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
it('renders the owned task on its agent node', async () => {
|
||||
teamPayload = teamWithOwnedTask()
|
||||
teamWithOwnedTask()
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -463,7 +506,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
})
|
||||
|
||||
it('opens the shared task window from a board row (markdown first, no native select)', async () => {
|
||||
teamPayload = teamWithOwnedTask()
|
||||
teamWithOwnedTask()
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -473,7 +516,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
expect(row).not.toBeNull()
|
||||
await act(async () => { row.click() })
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
// View mode first: the description renders as markdown, edited only on demand.
|
||||
expect(dialog.textContent).toContain('细节')
|
||||
@@ -489,7 +532,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
})
|
||||
|
||||
it('creates a task through the dialog (host Input + modal) and posts the CAS-free create', async () => {
|
||||
teamPayload = teamWithOwnedTask()
|
||||
teamWithOwnedTask()
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -506,7 +549,7 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
setter?.call(field, '新任务标题')
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
const save = [...dialog.querySelectorAll('button')].find(button => button.textContent?.includes('新建任务'))
|
||||
expect(save).toBeDefined()
|
||||
@@ -552,25 +595,17 @@ describe('Tasks page: owned tasks, host-primitive controls, draggable output', (
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
expect(document.querySelector('[data-floating-window]')).toBeNull()
|
||||
unmount()
|
||||
await act(async () => { unmount() })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tasks page: the shared task window', () => {
|
||||
it('opens from an agent node task line and saves a multi-line edit', async () => {
|
||||
teamPayload = {
|
||||
available: true,
|
||||
team: {
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', model: 'glm-5.3', diagnostics: [] },
|
||||
],
|
||||
tasks: [{
|
||||
id: 't1', revision: 4, subject: '收窄卡片', description: '第一行\n\n- 第二行', status: 'in_progress',
|
||||
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
}],
|
||||
},
|
||||
}
|
||||
withTeam({
|
||||
tasks: [teamTask({
|
||||
revision: 4, description: '第一行\n\n- 第二行', ownerName: 'writer',
|
||||
})],
|
||||
})
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -580,7 +615,7 @@ describe('Tasks page: the shared task window', () => {
|
||||
const taskLine = node.querySelector('[role="button"]') as HTMLElement
|
||||
expect(taskLine).not.toBeNull()
|
||||
await act(async () => { taskLine.click() })
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.textContent).toContain('收窄卡片')
|
||||
// Markdown view renders the multi-line body before any editing.
|
||||
@@ -605,20 +640,14 @@ describe('Tasks page: the shared task window', () => {
|
||||
})
|
||||
|
||||
it('reassigns the owner straight from the window (CAS on the current revision)', async () => {
|
||||
teamPayload = {
|
||||
available: true,
|
||||
team: {
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', status: 'running', diagnostics: [] },
|
||||
{ id: 'child-1', name: 'reviewer', role: 'teammate', status: 'idle', diagnostics: [] },
|
||||
],
|
||||
tasks: [{
|
||||
id: 't1', revision: 7, subject: '收窄卡片', description: '', status: 'pending',
|
||||
ownerName: 'writer', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
|
||||
}],
|
||||
},
|
||||
}
|
||||
withTeam({
|
||||
members: [
|
||||
{ id: 'root', name: 'lead', role: 'lead', phase: 'active' },
|
||||
{ id: 'child-0', name: 'writer', role: 'teammate', phase: 'active' },
|
||||
{ id: 'child-1', name: 'reviewer', role: 'teammate', phase: 'active' },
|
||||
],
|
||||
tasks: [teamTask({ revision: 7, description: '', status: 'pending', ownerName: 'writer' })],
|
||||
})
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
@@ -626,7 +655,7 @@ describe('Tasks page: the shared task window', () => {
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement
|
||||
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
const reviewer = [...dialog.querySelectorAll('button')].find(button => button.textContent === 'reviewer')
|
||||
expect(reviewer).toBeDefined()
|
||||
await act(async () => { reviewer?.click() })
|
||||
@@ -636,6 +665,86 @@ describe('Tasks page: the shared task window', () => {
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('follows the board state machine: claim a queued task, complete a claimed one', async () => {
|
||||
withTeam({ tasks: [teamTask({ revision: 4, subject: '收窄卡片', description: '', status: 'pending' })] })
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
// The action row is the shell's FOOTER (outside the scrolling body), so it
|
||||
// stays put while a long description scrolls.
|
||||
const footer = dialog.querySelector('[data-window-footer]') as HTMLElement
|
||||
expect(footer).not.toBeNull()
|
||||
const button = (label: string): HTMLButtonElement =>
|
||||
[...footer.querySelectorAll('button')].find(candidate => candidate.textContent?.includes(label)) as HTMLButtonElement
|
||||
|
||||
// A QUEUED task cannot be completed — the service enforces
|
||||
// pending → claim → in_progress → complete, so the window offers claim.
|
||||
expect(button('完成')).toBeUndefined()
|
||||
const claim = button('认领')
|
||||
expect(claim).toBeDefined()
|
||||
|
||||
// A stale revision is a REAL failure the reader must see: 0.1.7 throws a
|
||||
// typed rejection, the host maps it to `team-conflict` (409).
|
||||
writeRejection = { status: 409, code: 'team-conflict', message: 'stale team task "t1" revision 4' }
|
||||
await act(async () => { claim.click() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(teamMutations.at(-1)?.body).toMatchObject({ action: 'claim', expectedRevision: 4 })
|
||||
expect(dialog.textContent).toContain('已被他人修改')
|
||||
// The window stays open on the failed write: nothing was committed.
|
||||
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
|
||||
|
||||
// A clean write clears the note.
|
||||
writeRejection = undefined
|
||||
await act(async () => { claim.click() })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(dialog.textContent).not.toContain('已被他人修改')
|
||||
await act(async () => { unmount() })
|
||||
})
|
||||
|
||||
it('refuses to claim a task whose blockers are still open', async () => {
|
||||
withTeam({ tasks: [teamTask({ revision: 2, ready: false, status: 'pending' })] })
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
const dialog = document.querySelector('[data-floating-window]') as HTMLElement
|
||||
const claim = [...dialog.querySelectorAll('[data-window-footer] button')]
|
||||
.find(candidate => candidate.textContent?.includes('认领')) as HTMLButtonElement
|
||||
// `claim` would be refused with TEAM_TASK_BLOCKED, so it never leaves.
|
||||
expect(claim.disabled).toBe(true)
|
||||
await act(async () => { unmount() })
|
||||
})
|
||||
|
||||
it('closes the task window on Escape, never on an outside click', async () => {
|
||||
withTeam({ tasks: [teamTask()] })
|
||||
const store = makeStore(snapshotWithChildren(1))
|
||||
const { container, unmount } = renderRoot(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
const row = container.querySelector('button[aria-label^="任务详情"]') as HTMLButtonElement
|
||||
await act(async () => { row.click() })
|
||||
// The window portals to the body: query the DOCUMENT, not the page root.
|
||||
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
|
||||
await act(async () => {
|
||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
})
|
||||
expect(document.querySelector('[data-floating-window]')).not.toBeNull()
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
})
|
||||
expect(document.querySelector('[data-floating-window]')).toBeNull()
|
||||
await act(async () => { unmount() })
|
||||
})
|
||||
|
||||
it('draws the two-segment card: kind badge + name on top, state bar below', async () => {
|
||||
const store = makeStore(snapshotWithChildren(2))
|
||||
const { container, unmount } = renderRoot(
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
+138
-89
@@ -1,25 +1,23 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
import type { Context, SidebarTeamTaskView } from '../src/context-types.ts'
|
||||
|
||||
/** One task row. */
|
||||
function task(over: Partial<SidebarTeamTaskView> = {}): SidebarTeamTaskView {
|
||||
@@ -41,116 +39,144 @@ 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 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
/** 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 union', async () => {
|
||||
it('passes the shaped request through and returns the committed view', async () => {
|
||||
const created = task({ id: 'task-9', subject: '新任务' })
|
||||
const teams = {
|
||||
tryMembership: vi.fn(async () => ({ role: 'lead' })),
|
||||
remoteCreateTask: vi.fn(async () => ({ ok: true, value: created })),
|
||||
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.remoteCreateTask).toHaveBeenCalledWith(
|
||||
expect(teams.createTask).toHaveBeenCalledWith(
|
||||
{ id: 'root' },
|
||||
{ subject: '新任务', description: '详', blockedBy: ['task-1'] },
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to 503 on a mutation when the layer is absent', async () => {
|
||||
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 leads no team', async () => {
|
||||
const teams = { tryMembership: vi.fn(async () => undefined) }
|
||||
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('rejects a missing subject as bad-request', async () => {
|
||||
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })) }
|
||||
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, conflict union untouched', async () => {
|
||||
const conflict = { ok: false, error: { code: 'team-task-conflict', message: 'stale revision' } }
|
||||
it('passes the CAS request through and returns the committed view', async () => {
|
||||
const done = task({ status: 'completed', revision: 2 })
|
||||
const teams = {
|
||||
tryMembership: vi.fn(async () => ({ role: 'lead' })),
|
||||
remoteUpdateTask: vi.fn(async () => conflict),
|
||||
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: 3, action: 'complete',
|
||||
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 1, action: 'complete',
|
||||
})
|
||||
expect(result).toEqual(conflict)
|
||||
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
|
||||
expect(result).toEqual({ ok: true, value: done })
|
||||
expect(teams.updateTask).toHaveBeenCalledWith(
|
||||
{ id: 'root' },
|
||||
{ taskId: 'task-1', expectedRevision: 3, action: 'complete' },
|
||||
{ taskId: 'task-1', expectedRevision: 1, action: 'complete' },
|
||||
)
|
||||
})
|
||||
|
||||
it('validates taskId / expectedRevision / action', async () => {
|
||||
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })), remoteUpdateTask: vi.fn() }
|
||||
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' }))
|
||||
@@ -160,21 +186,44 @@ describe('teams.taskUpdate route', () => {
|
||||
.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()
|
||||
expect(teams.updateTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards optional edit fields when present', async () => {
|
||||
it('forwards optional edit fields only when present', async () => {
|
||||
const teams = {
|
||||
tryMembership: vi.fn(async () => ({ role: 'lead' })),
|
||||
remoteUpdateTask: vi.fn(async () => ({ ok: true, value: task() })),
|
||||
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.remoteUpdateTask).toHaveBeenCalledWith(
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user