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:
Menghuan1918
2026-09-27 22:19:05 +08:00
parent 421fd3fde7
commit d8bc6566df
40 changed files with 1352 additions and 576 deletions
-3
View File
@@ -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 })
+48 -7
View File
@@ -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
View File
@@ -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(
+120
View File
@@ -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
View File
@@ -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')
})
})