团队功能在 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)
121 lines
4.8 KiB
TypeScript
121 lines
4.8 KiB
TypeScript
/**
|
|
* 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')
|
|
})
|
|
})
|