From 1dc518f217297e6369fc3ecd7a53ff66df2dd5fb Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 16:37:10 +0800 Subject: [PATCH 01/25] feat(agent-team): drive the Web Team panel from the agentTeam projection The Team panel read a Remote snapshot on open and behind an explicit refresh button, while the subagent catalog panel updates from Host projection frames. The Team projection was Host-only and its `apply` mutated state in place, so the projection registry could never publish a client view for it. Publish a durable-only `agentTeam` wire view: roster rows with their durable phase, non-deleted task views through the same readiness, owner-name, and write-overlap derivation the task board returns to agents, and `failure` beside the last valid state when a persisted Team record is rejected. `apply` now returns a new state per applied Team event and replaces only the touched collection, so mailbox-only events reuse the previous view reference and publish nothing. The Web panel derives every row from the shared Session store: the Lead from the current Session's subagent address, running/inactive from Session status, and the model from the member Session's durable `modelSelection` projection. Opening requests the Lead's projection baseline when the store has none, a failed read shows a retry control, and the refresh button and Remote mount are gone. The `agentTeams/view` Remote method remains for callers that need live availability and the configured model. The Team panel web e2e now asserts that Host-side task creation and claiming reach the open panel without any user action. --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 4 + .../feature/2026-08-05-agent-teams.zh.md | 4 + apps/web/tests/agent-team-panel.e2e.ts | 12 +- .../agent-team-panel/task.expected.md | 3 +- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 7 +- packages/experimental/agent-team/README.zh.md | 7 +- .../experimental/agent-team/src/client.ts | 5 +- .../experimental/agent-team/src/invariant.ts | 4 +- .../experimental/agent-team/src/projection.ts | 154 +++++-- .../experimental/agent-team/src/task-board.ts | 47 +-- .../experimental/agent-team/src/task-view.ts | 62 +++ packages/experimental/agent-team/src/types.ts | 31 ++ .../agent-team/tests/persistence.spec.ts | 6 +- .../tests/projection-events.spec.ts | 159 +++++++- .../agent-team/tests/team.spec.ts | 6 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 21 +- .../client-ui-agent-team/README.zh.md | 21 +- .../client-ui-agent-team/package.json | 6 +- .../src/client/TeamAction.tsx | 155 +++---- .../client-ui-agent-team/src/client/index.ts | 16 +- .../src/client/locales.ts | 6 +- .../client-ui-agent-team/src/client/mount.ts | 57 +-- .../tests/browser-plugin.client.spec.ts | 196 ++------- .../tests/team-action.client.spec.tsx | 381 +++++++++--------- .../client-ui-agent-team/tsconfig.json | 5 +- pnpm-lock.yaml | 9 +- 29 files changed, 777 insertions(+), 619 deletions(-) create mode 100644 packages/experimental/agent-team/src/task-view.ts diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index a4f2c8968a..f2228c4045 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc -2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f +2026-08-05-agent-teams.md: 29b53ee5057c44b9ca4d959cf865e4b989b4070e +2026-08-05-agent-teams.zh.md: e41812e25b41c96cf92cc2ee876ebc0d6c566bcd diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 45c7e599e0..29b53ee505 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -54,6 +54,10 @@ All members use the same cwd and observe writes immediately. The policy tells me Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts. +## Web projection + +The Web panel reads the Lead Session's `agentTeam` wire projection instead of polling a Remote read, because the projection registry already broadcasts one Host-wide frame per changed client view to every connected browser, and the subagent catalog panel proves that path needs no refresh control. The wire view carries durable facts only: roster rows with their durable phase, non-deleted task views with the same owner-name, readiness, and write-overlap derivation the task board returns to agents, and the projection `failure` beside the last valid state so a rejected persisted record is visible instead of appearing as a stale but healthy board. Live turn activity and the model stay out of the view because they are process-local; the browser overlays Session status and the member Session's `modelSelection` projection, and shows no model before a durable selection or request exists. Publication requires the projection `apply` to return a new state object per applied Team event and to replace only the touched collection, so mailbox-only events keep the previous view reference and publish nothing. The `agentTeams/view` Remote method remains for callers that need live availability and the configured model. + ## Alternatives considered **Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index dbb2931bdc..e41812e25b 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -54,6 +54,10 @@ Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/messag Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。 +## Web projection + +Web panel 读取 Lead Session 的 `agentTeam` wire projection 而不是轮询 Remote 读取,因为 projection registry 已经向每个已连接浏览器广播每次客户端视图变化的一帧 Host 级 frame,而 subagent catalog panel 证明这条路径不需要刷新控件。wire 视图只承载持久事实:带持久 phase 的 roster row、与任务板返回给 agent 相同的 owner 名称、就绪状态与写入范围重叠派生的未删除任务视图,以及与最后有效状态并列的 projection `failure`,使被拒绝的持久记录可见而不是表现为陈旧但健康的任务板。实时轮次活动与模型不进入视图,因为它们是进程本地的;浏览器叠加 Session 状态与成员 Session 的 `modelSelection` projection,并在持久选择或请求存在之前不显示模型。发布要求 projection `apply` 对每个已应用的 Team 事件返回新的 state 对象并只替换被触及的集合,因此仅邮箱事件保留上一个视图引用且不发布。`agentTeams/view` Remote method 保留给需要实时可用性与配置模型的调用方。 + ## Alternatives considered **用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 3e5ad5e0c2..3ffb5dedff 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,5 +1,5 @@ // Keyless assembled-browser coverage for the opt-in Agent Teams bundle -// over the real Host Typert Remote flow. +// over the real Host projection frame flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { readFileSync } from 'node:fs' @@ -80,7 +80,7 @@ describe('web e2e: Agent Teams panel', () => { await scaffold?.close() }) - it('displays agent-owned tasks through a read-only board', async () => { + it('displays agent-owned task changes through a read-only board without a refresh action', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) @@ -88,18 +88,18 @@ describe('web e2e: Agent Teams panel', () => { await action.getByText('lead').waitFor() expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) + expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0) + // Host-side Team mutations reach the open panel through projection frames only. const agent = scaffold.ctx.agents.list()[0]! const task = await scaffold.ctx.agentTeams.createTask(agent, { subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'], }) - await action.getByRole('button', { name: 'Refresh Team' }).click() await action.getByText('Agent task', { exact: true }).waitFor() await action.getByText('Owner: Unowned', { exact: true }).waitFor() await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'claim', }) - await action.getByRole('button', { name: 'Refresh Team' }).click() await action.getByText('In progress', { exact: true }).waitFor() await action.getByText('Owner: lead', { exact: true }).waitFor() expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) @@ -141,9 +141,6 @@ describe('web e2e: Agent Teams panel', () => { await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() expect(await panel.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Tab') - expect(await panel.getByRole('button', { name: 'Refresh Team' }) - .evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Tab') expect(await panel.getByRole('button', { name: 'Close', exact: true }) .evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Escape') @@ -152,7 +149,6 @@ describe('web e2e: Agent Teams panel', () => { await page.keyboard.press('Enter') await panel.waitFor() await page.keyboard.press('Tab') - await page.keyboard.press('Tab') await page.keyboard.press('Enter') await panel.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 5184f24d29..d0a286ce9a 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,9 +1,8 @@ - dialog "Agent Team": - strong: Agent Team - - button "Refresh Team" - button "Close" - heading "Members" [level=3] - - 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]' + - button "lead Inactive" [disabled] - heading "Shared tasks" [level=3] - article: - strong: Agent task diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 52019ce198..00326bcff4 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 6ae7d82214ca9e041324e353057ba6850675c043 -README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e +README.md: cc2a7a546715d88c5fc7bacf103fbde2e51befc5 +README.zh.md: 321200cf7395277933b00b62f370e2b14629720d diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 6ae7d82214..cc2a7a5467 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -118,7 +118,8 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08- | [`src/mailbox.ts`](src/mailbox.ts) | Durable queue, target-local dispatch, acknowledgement, and recovery | | [`src/task-board.ts`](src/task-board.ts) | Task CAS commands, DAG validation, and derived views | | [`src/journal.ts`](src/journal.ts) | Serialized Lead-log transactions and commit notification | -| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events | +| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events and publishes the `agentTeam` client view | +| [`src/task-view.ts`](src/task-view.ts) | Pure task readiness, owner-name, and write-overlap derivation shared by the task board and the client view | | [`src/activity.ts`](src/activity.ts) | One-shot change waiters and disposal release | | [`src/lifecycle.ts`](src/lifecycle.ts) | Shared admission cutoff and bounded settlement | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion that replays candidate events before append | @@ -171,9 +172,9 @@ Read these pages when the package-level contract is not enough. They move from t -### Browser Remote +### Browser projection and Remote -`TeamService` exposes the read-only `agentTeams/view` Remote method for browser clients. Task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` exports browser-safe roster and task views. +The `agentTeam` Session projection publishes a durable client view of the Lead Session: the roster with each member's durable phase, the non-deleted task board with owner names, readiness, and write-scope overlap warnings, and `failure` when a persisted Team record was rejected, after which the roster and tasks stay at the last valid state. Every applied Team event replaces the projection state object and only the collection it touched, so the projection registry publishes one Host-wide frame per roster or task change and none for mailbox-only changes. Live turn activity and the selected model are not part of the view; the Web UI overlays them from Session status and the member Session's `modelSelection` projection. `TeamService` also exposes the read-only `agentTeams/view` Remote method, which adds live availability and the configured model; task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the generated Client contribution, and `./client` exports the browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 89176c285a..321200cf73 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -118,7 +118,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 | [`src/mailbox.ts`](src/mailbox.ts) | 持久队列、目标本地投递、确认与恢复 | | [`src/task-board.ts`](src/task-board.ts) | 任务 CAS 命令、DAG 校验与派生视图 | | [`src/journal.ts`](src/journal.ts) | 串行化的 Lead 日志事务与提交通知 | -| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件的严格回放投影 | +| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件、发布 `agentTeam` 客户端视图的严格回放投影 | +| [`src/task-view.ts`](src/task-view.ts) | 任务板与客户端视图共用的纯任务派生:就绪状态、owner 名称与写入范围重叠 | | [`src/activity.ts`](src/activity.ts) | 一次性变更等待者与 dispose(资源释放)时的等待解除 | | [`src/lifecycle.ts`](src/lifecycle.ts) | 共享准入截止与有界结算 | | [`src/invariant.ts`](src/invariant.ts) | 在 append 前回放候选事件的不变式伴生插件 | @@ -171,9 +172,9 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch -### 浏览器 Remote +### 浏览器投影与 Remote -`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。 +`agentTeam` Session 投影发布 Lead Session 的持久客户端视图:带每个成员持久 phase 的 roster、带 owner 名称、就绪状态与写入范围重叠警告的未删除任务板,以及在某条持久 Team 记录被拒绝时的 `failure`,此后 roster 与任务停留在最后有效状态。每个已应用的 Team 事件都会替换投影 state 对象,且只替换它触及的集合,因此投影注册表对每次 roster 或任务变化发布一帧 Host 级 frame,对仅邮箱的变化不发布。实时轮次活动与所选模型不在视图内;Web UI 从 Session 状态和成员 Session 的 `modelSelection` 投影叠加它们。`TeamService` 同时公开只读的 `agentTeams/view` Remote method,它额外提供实时可用性与配置的模型;任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出生成的 Client contribution,`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index b1fd173d10..fc5e7e66e5 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -1,7 +1,10 @@ -/** Client-safe Agent Teams roster and task views. */ +/** Client-safe Agent Teams roster and task views plus the `agentTeam` projection vocabulary. */ export type { + TeamMemberPhase, + TeamMemberProjection, TeamMemberView, + TeamProjection, TeamTaskId, TeamTaskStatus, TeamTaskView, diff --git a/packages/experimental/agent-team/src/invariant.ts b/packages/experimental/agent-team/src/invariant.ts index dc5fd3ddc1..442df6a6e6 100644 --- a/packages/experimental/agent-team/src/invariant.ts +++ b/packages/experimental/agent-team/src/invariant.ts @@ -16,7 +16,7 @@ export const name = 'team-invariant' /** Invariant registry required by the companion. */ export const inject = ['invariants'] -/** Validate candidate Team events against the projected committed prefix. */ +/** Validate candidate Team events against the projected committed prefix; `apply` never mutates the committed state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return @@ -24,7 +24,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /* v8 ignore next -- non-Team Session events have no Agent Teams invariant. */ if (!isTeamEvent(event)) return const state = ctx.sessionProjections.stateOf(session, 'agentTeam') as TeamProjectionState - const candidate = teamProjectionDefinition.apply(structuredClone(state), event) + const candidate = teamProjectionDefinition.apply(state, event) if (candidate.failure !== undefined) { fail(`session event ${event.seq} violates the Agent Teams stream: ${candidate.failure}`) } diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index e4ade61e9e..13c83d4175 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -1,4 +1,4 @@ -/** Host-only Team state projected incrementally from committed Session events. */ +/** Team state projected incrementally from committed Session events, with a durable-only client view. */ import { z } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' @@ -7,10 +7,13 @@ import type { SessionEvent, SessionEventMap, SessionId } from '@deepseek-ai/dsh- import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { TeamId, + TeamMemberProjection, TeamMemberSnapshot, TeamMessageId, TeamMessageSnapshot, + TeamProjection, TeamTaskSnapshot, + TeamTaskView, } from './types.ts' import { TeamId as toTeamId, @@ -18,6 +21,7 @@ import { TeamTaskId as toTeamTaskId, } from './types.ts' import { assertTaskGraphCandidate } from './task-graph.ts' +import { projectTaskView } from './task-view.ts' const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) const positiveSafeInteger = nonNegativeSafeInteger.min(1) @@ -119,20 +123,24 @@ const teamMessageDeliveredEventSchema = z.object({ targetId: sessionIdSchema, }).strict() as z.ZodType -/** Current Team state selected by durable Team identity. */ +/** + * Current Team state selected by durable Team identity. Every applied Team + * event produces a new state object and replaces only the collection it + * touched; untouched collections keep their references. + */ export interface TeamState { readonly id: TeamId - readonly members: TeamMemberSnapshot[] - readonly tasks: TeamTaskSnapshot[] - readonly messages: TeamMessageSnapshot[] - readonly delivered: TeamMessageId[] - nextTaskNumber: number + readonly members: readonly TeamMemberSnapshot[] + readonly tasks: readonly TeamTaskSnapshot[] + readonly messages: readonly TeamMessageSnapshot[] + readonly delivered: readonly TeamMessageId[] + readonly nextTaskNumber: number } /** * Construct empty state for one Team identity. * @param rootId - root Session identity. - * @returns mutable empty Team state. + * @returns empty Team state. */ export function emptyTeamState(rootId: SessionId): TeamProjectionState { return { @@ -147,7 +155,7 @@ export function emptyTeamState(rootId: SessionId): TeamProjectionState { /** Checkpoint-safe state for the Team owned by the projected Session. */ export interface TeamProjectionState extends TeamState { - failure?: string + readonly failure?: string } declare module '@deepseek-ai/dsh-session-projection/types' { @@ -214,23 +222,30 @@ function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent { } } -function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): void { - if (state.failure !== undefined) return - if (!isTeamEvent(event)) return +function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): TeamProjectionState { + if (state.failure !== undefined) return state + if (!isTeamEvent(event)) return state try { const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) - if (selector.teamId !== state.id) return + if (selector.teamId !== state.id) return state if (selector.version !== 2) { throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) } - applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) + return applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) } catch (error: unknown) { /* v8 ignore next -- the owned Team transition throws Error instances. */ - state.failure = error instanceof Error ? error.message : String(error) + return { ...state, failure: error instanceof Error ? error.message : String(error) } } } -function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void { +function replaceAt(items: readonly T[], index: number, item: T): T[] { + const next = [...items] + if (index < 0) next.push(item) + else next[index] = item + return next +} + +function applyCurrentTeamEvent(state: TeamProjectionState, event: TeamSessionEvent): TeamProjectionState { switch (event.type) { case 'team/member': { const member = event.data.member @@ -250,9 +265,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void throw new Error(`teammate "${member.name}" has an invalid ${prior.phase} -> ${member.phase} transition`) } } - if (index < 0) state.members.push(member) - else state.members[index] = member - break + return { ...state, members: replaceAt(state.members, index, member) } } case 'team/task': { const task = event.data.task @@ -265,48 +278,121 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void throw new Error(`team task "${task.id}" revision is not contiguous`) } assertTaskGraphCandidate(state.tasks, task) + let nextTaskNumber = state.nextTaskNumber const match = numericTaskIdPattern.exec(task.id) if (match !== null) { const number = Number(match[1]) - state.nextTaskNumber = Math.max( - state.nextTaskNumber, + nextTaskNumber = Math.max( + nextTaskNumber, number === Number.MAX_SAFE_INTEGER ? number : number + 1, ) } - if (index < 0) state.tasks.push(task) - else state.tasks[index] = task - break + return { ...state, tasks: replaceAt(state.tasks, index, task), nextTaskNumber } } case 'team/message/queued': { const message = event.data.message if (state.messages.some(candidate => candidate.id === message.id)) { throw new Error(`team message "${message.id}" was queued twice`) } - state.messages.push(message) - break + return { ...state, messages: [...state.messages, message] } } case 'team/message/delivered': { const queued = state.messages.find(message => message.id === event.data.messageId) if (queued === undefined) throw new Error(`team message "${event.data.messageId}" was delivered before queueing`) if (queued.targetId !== event.data.targetId) throw new Error(`team message "${event.data.messageId}" target changed`) if (state.delivered.includes(event.data.messageId)) throw new Error(`team message "${event.data.messageId}" was delivered twice`) - state.delivered.push(event.data.messageId) - break + return { ...state, delivered: [...state.delivered, event.data.messageId] } } /* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */ default: - return + return state } } -/** Host-only Team projection selected by the projected Session identity. */ +const teamMemberProjectionSchema = z.object({ + id: sessionIdSchema, + name: z.string(), + role: z.enum(['lead', 'teammate']), + phase: z.enum(['provisioning', 'active', 'failed']), + description: z.string().optional(), + provider: z.string().optional(), + context: z.enum(['fresh', 'fork']).optional(), + error: z.string().optional(), +}).strict() as z.ZodType + +const teamTaskViewSchema = z.object({ + id: teamTaskIdSchema, + revision: positiveSafeInteger, + subject: z.string(), + description: z.string(), + status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), + blockedBy: z.array(teamTaskIdSchema), + writeScopes: z.array(z.string()), + ownerName: z.string().optional(), + ready: z.boolean(), + writeScopeWarnings: z.array(z.string()), +}).strict() as z.ZodType + +const teamProjectionSchema = z.object({ + members: z.array(teamMemberProjectionSchema), + tasks: z.array(teamTaskViewSchema), + failure: z.string().optional(), +}).strict() as z.ZodType + +/** Client views keyed by the member and task collections they were derived from. */ +const teamProjectionViews = new WeakMap>() + +function buildTeamProjection(state: TeamProjectionState): TeamProjection { + const rootId = brandString(state.id) + const members: TeamMemberProjection[] = [{ id: rootId, name: 'lead', role: 'lead', phase: 'active' }] + for (const member of state.members) { + members.push({ + id: member.id, + name: member.name, + role: 'teammate', + phase: member.phase, + description: member.description, + provider: member.provider, + context: member.context, + ...member.error === undefined ? {} : { error: member.error }, + }) + } + return { + members, + tasks: state.tasks + .filter(task => task.status !== 'deleted') + .map(task => projectTaskView(rootId, state, task)), + ...state.failure === undefined ? {} : { failure: state.failure }, + } +} + +/** + * Durable client view of one Team state. Mailbox-only state changes reuse the + * previous view reference, so the live drive publishes nothing for them. + * @param state - current Team state. + * @returns the roster and non-deleted task board, plus any projection failure. + */ +export function teamProjectionView(state: TeamProjectionState): TeamProjection { + if (state.failure !== undefined) return buildTeamProjection(state) + let byTasks = teamProjectionViews.get(state.members) + if (byTasks === undefined) { + byTasks = new WeakMap() + teamProjectionViews.set(state.members, byTasks) + } + let view = byTasks.get(state.tasks) + if (view === undefined) { + view = buildTeamProjection(state) + byTasks.set(state.tasks, view) + } + return view +} + +/** Team projection selected by the projected Session identity; the wire view carries durable roster and task state only. */ export const teamProjectionDefinition = { key: 'agentTeam', stateVersion: 4, stateSchema: teamProjectionEntrySchema, init: header => emptyTeamState(header.id), - apply: (state, event) => { - applyProjectionEvent(state, event) - return state - }, + apply: applyProjectionEvent, + wire: { viewSchema: teamProjectionSchema, view: teamProjectionView }, } satisfies ProjectionDefinition<'agentTeam', TeamProjectionState> diff --git a/packages/experimental/agent-team/src/task-board.ts b/packages/experimental/agent-team/src/task-board.ts index cc7064ceb6..88f2baeb4e 100644 --- a/packages/experimental/agent-team/src/task-board.ts +++ b/packages/experimental/agent-team/src/task-board.ts @@ -15,13 +15,9 @@ import type { TeamTaskView, UpdateTeamTaskRequest, } from './types.ts' +import { projectTaskView, taskReady } from './task-view.ts' import { requiredText, writeScope } from './validation.ts' -/** Whether two normalized file or directory prefixes overlap on path components. */ -function scopesOverlap(left: string, right: string): boolean { - return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) -} - const TASK_GRAPH_ERROR_CODES: Record = { missing: 'TEAM_TASK_NOT_FOUND', duplicate: 'TEAM_INVALID_ARGUMENT', @@ -134,7 +130,7 @@ export class TeamTaskBoard { if (current.ownerId !== undefined && current.ownerId !== caller.id) { throw new TeamError(`team task "${current.id}" is owned by another member`, 'TEAM_TASK_ALREADY_CLAIMED') } - if (current.status !== 'pending' || !this.taskReady(state, current)) { + if (current.status !== 'pending' || !taskReady(state, current)) { throw new TeamError(`team task "${current.id}" is not ready to claim`, 'TEAM_TASK_BLOCKED') } next = { ...current, status: 'in_progress', ownerId: caller.id } @@ -185,7 +181,7 @@ export class TeamTaskBoard { next = this.withoutOwner({ ...current, status: 'pending' }) break } - if (!this.taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') + if (!taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') const assignee = resolveActiveMember(root, state, request.owner) next = { ...current, status: 'in_progress', ownerId: assignee.id } break @@ -251,47 +247,14 @@ export class TeamTaskBoard { } } - /** Whether all current blockers completed. */ - private taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { - return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed') - } - /** Remove an optional owner field under exactOptionalPropertyTypes. */ private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot { const { ownerId: _ownerId, ...without } = task return without } - /** - * Build one task view with owner name, readiness, and advisory write overlaps. - * A committing caller may pass its pre-append state because `task` supplies the - * new value explicitly; owner names, blocker readiness, and other task scopes - * do not change when that snapshot is appended. - */ + /** View one task against the supplied state through the shared derivation. */ private taskView(root: Agent, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { - const ownerName = task.ownerId === undefined - ? undefined - : task.ownerId === root.id - ? 'lead' - : state.members.find(member => member.id === task.ownerId)?.name - const warnings = new Set() - for (const other of state.tasks) { - if (other.id === task.id || other.status !== 'in_progress') continue - if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { - warnings.add(`write scopes overlap with ${other.id}`) - } - } - return { - id: task.id, - revision: task.revision, - subject: task.subject, - description: task.description, - status: task.status, - blockedBy: structuredClone(task.blockedBy), - writeScopes: structuredClone(task.writeScopes), - ...ownerName === undefined ? {} : { ownerName }, - ready: task.status === 'pending' && this.taskReady(state, task), - writeScopeWarnings: [...warnings], - } + return projectTaskView(root.id, state, task) } } diff --git a/packages/experimental/agent-team/src/task-view.ts b/packages/experimental/agent-team/src/task-view.ts new file mode 100644 index 0000000000..a2f425782e --- /dev/null +++ b/packages/experimental/agent-team/src/task-view.ts @@ -0,0 +1,62 @@ +/** Pure task-view derivation shared by the task board and the client projection. */ + +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { TeamState } from './projection.ts' +import type { TeamTaskSnapshot, TeamTaskView } from './types.ts' + +/** + * Whether two normalized file or directory prefixes overlap on path components. + * @param left - normalized write scope. + * @param right - normalized write scope. + * @returns whether either scope contains the other. + */ +export function scopesOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) +} + +/** + * Whether every current blocker of one task completed. + * @param state - Team state supplying sibling tasks. + * @param task - durable task snapshot to test. + * @returns whether the task has no incomplete blockers. + */ +export function taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { + return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed') +} + +/** + * Derive one task view with owner name, readiness, and advisory write overlaps. + * A committing caller may pass its pre-append state because `task` supplies the + * new value explicitly; owner names, blocker readiness, and other task scopes + * do not change when that snapshot is appended. + * @param rootId - Team Lead Session identity named `lead` in owner names. + * @param state - Team state supplying members and sibling tasks. + * @param task - durable task snapshot to view. + * @returns a detached task view. + */ +export function projectTaskView(rootId: SessionId, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { + const ownerName = task.ownerId === undefined + ? undefined + : task.ownerId === rootId + ? 'lead' + : state.members.find(member => member.id === task.ownerId)?.name + const warnings = new Set() + for (const other of state.tasks) { + if (other.id === task.id || other.status !== 'in_progress') continue + if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { + warnings.add(`write scopes overlap with ${other.id}`) + } + } + return { + id: task.id, + revision: task.revision, + subject: task.subject, + description: task.description, + status: task.status, + blockedBy: [...task.blockedBy], + writeScopes: [...task.writeScopes], + ...ownerName === undefined ? {} : { ownerName }, + ready: task.status === 'pending' && taskReady(state, task), + writeScopeWarnings: [...warnings], + } +} diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 77aa6fe620..10e82ff32d 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -102,6 +102,37 @@ export interface TeamView { readonly tasks: TeamTaskView[] } +/** One durable roster row published through the `agentTeam` Session projection. */ +export interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly error?: string +} + +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +export interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Durable roster and non-deleted task board of the Team rooted at the projected Session. */ + agentTeam: TeamProjection + } +} + /** One peer message retained until its target Session records it. */ export interface TeamMessageSnapshot { readonly id: TeamMessageId diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 5237fc0301..f7ee5d41cb 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -26,9 +26,9 @@ const contexts = new Set() /** Detached durable Team read through the same projection definition as the service. */ function durable(agent: Agent): { - members: TeamMemberSnapshot[] - tasks: TeamTaskSnapshot[] - pendingMessages: TeamMessageSnapshot[] + members: readonly TeamMemberSnapshot[] + tasks: readonly TeamTaskSnapshot[] + pendingMessages: readonly TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 333f157eb0..02a837a0f3 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition, teamProjectionView } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts' @@ -424,4 +424,161 @@ describe('Agent Teams projection events', () => { } as unknown as SessionEvent expect(isEmptyState(projectTeam(ROOT, [inherited]))).toBe(true) }) + + describe('copy-on-write state and client view', () => { + const memberEvent = event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)) + const taskEvent = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(1)) + const queuedEvent = event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(2)) + const deliveredEvent = event('team/message/delivered', { + version: 2, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, + }, SessionSeq(3)) + + it('returns the same state for unrelated events and a new state that replaces only the touched collection', () => { + const initial = project(ROOT, []) + expect(teamProjectionDefinition.apply(initial, { type: 'turn/start', data: { turn: 1 }, seq: 0, time: 0 } as SessionEvent)).toBe(initial) + expect(teamProjectionDefinition.apply(initial, event('team/member', { + version: 2, teamId: TeamId('ancestor'), member: member(), + }, SessionSeq(0)))).toBe(initial) + + const withMember = teamProjectionDefinition.apply(initial, memberEvent) + expect(withMember).not.toBe(initial) + expect(initial.members).toEqual([]) + expect(withMember.members).toHaveLength(1) + expect(withMember.tasks).toBe(initial.tasks) + + const withTask = teamProjectionDefinition.apply(withMember, taskEvent) + expect(withTask.members).toBe(withMember.members) + expect(withTask.tasks).not.toBe(withMember.tasks) + expect(withMember.tasks).toEqual([]) + + const withMail = teamProjectionDefinition.apply(withTask, queuedEvent) + expect(withMail.members).toBe(withTask.members) + expect(withMail.tasks).toBe(withTask.tasks) + expect(withMail.messages).not.toBe(withTask.messages) + expect(withTask.messages).toEqual([]) + + const withReceipt = teamProjectionDefinition.apply(withMail, deliveredEvent) + expect(withReceipt.delivered).not.toBe(withMail.delivered) + expect(withMail.delivered).toEqual([]) + expect(teamProjectionDefinition.apply(withReceipt, { ...deliveredEvent, seq: SessionSeq(4) })).not.toBe(withReceipt) + }) + + it('enters failure through a new state that keeps the last valid collections', () => { + const valid = project(ROOT, [memberEvent, taskEvent]) + const failed = teamProjectionDefinition.apply(valid, event('team/task', { + version: 1 as 2, teamId: TEAM, task: task(), + }, SessionSeq(2))) + expect(failed).not.toBe(valid) + expect(valid.failure).toBeUndefined() + expect(failed.failure).toMatch(/unsupported Agent Teams event version 1/) + expect(failed.members).toBe(valid.members) + expect(failed.tasks).toBe(valid.tasks) + expect(teamProjectionDefinition.apply(failed, queuedEvent)).toBe(failed) + }) + + it('views durable roster and non-deleted derived tasks and reuses the view across mailbox-only changes', () => { + const activeMember = event('team/member', { version: 2, teamId: TEAM, member: member({ phase: 'active' }) }, SessionSeq(1)) + const claimed = event('team/task', { + version: 2, teamId: TEAM, task: task({ revision: 2, status: 'in_progress', ownerId: CHILD, writeScopes: ['src'] }), + }, SessionSeq(2)) + const blocked = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-2'), blockedBy: [TeamTaskId('task-1')], writeScopes: ['src/web'] }), + }, SessionSeq(3)) + const leadOwned = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-3'), revision: 1, status: 'in_progress', ownerId: ROOT }), + }, SessionSeq(4)) + const deleted = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-3'), revision: 2, status: 'deleted', ownerId: ROOT }), + }, SessionSeq(5)) + const state = project(ROOT, [memberEvent, activeMember, taskEvent, claimed, blocked, leadOwned, deleted]) + const view = teamProjectionView(state) + expect(teamProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + expect(view).toEqual({ + members: [ + { id: ROOT, name: 'lead', role: 'lead', phase: 'active' }, + { id: CHILD, name: 'worker-a', role: 'teammate', phase: 'active', description: 'worker', provider: 'spawn', context: 'fresh' }, + ], + tasks: [ + { + id: 'task-1', revision: 2, subject: 'subject', description: 'description', status: 'in_progress', + blockedBy: [], writeScopes: ['src'], ownerName: 'worker-a', ready: false, writeScopeWarnings: [], + }, + { + id: 'task-2', revision: 1, subject: 'subject', description: 'description', status: 'pending', + blockedBy: ['task-1'], writeScopes: ['src/web'], ready: false, writeScopeWarnings: ['write scopes overlap with task-1'], + }, + ], + }) + expect(view).not.toHaveProperty('failure') + expect(teamProjectionView(state)).toBe(view) + + const withMail = teamProjectionDefinition.apply(state, { ...queuedEvent, seq: SessionSeq(6) }) + expect(teamProjectionView(withMail)).toBe(view) + const withMember = teamProjectionDefinition.apply(withMail, event('team/member', { + version: 2, teamId: TEAM, member: member({ id: SessionId('child-b'), name: 'worker-b' }), + }, SessionSeq(7))) + const memberView = teamProjectionView(withMember) + expect(memberView).not.toBe(view) + expect(memberView.members.at(-1)).toEqual({ + id: 'child-b', name: 'worker-b', role: 'teammate', phase: 'provisioning', description: 'worker', provider: 'spawn', context: 'fresh', + }) + + const failedMember = event('team/member', { + version: 2, teamId: TEAM, member: member({ id: SessionId('child-b'), name: 'worker-b', phase: 'failed', error: 'provider failed' }), + }, SessionSeq(8)) + expect(teamProjectionView(teamProjectionDefinition.apply(withMember, failedMember)).members.at(-1)) + .toMatchObject({ phase: 'failed', error: 'provider failed' }) + }) + + it('publishes the failure with the last valid roster and tasks', () => { + const state = project(ROOT, [memberEvent, taskEvent, event('team/task', { + version: 1 as 2, teamId: TEAM, task: task(), + }, SessionSeq(2))]) + const view = teamProjectionView(state) + expect(view.failure).toMatch(/unsupported Agent Teams event version 1/) + expect(view.members).toHaveLength(2) + expect(view.tasks).toHaveLength(1) + expect(teamProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + }) + + it('drives live agentTeam publications per Session through the projection registry', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + const published: Array<{ session: SessionId; key: string; value: unknown }> = [] + ctx.sessionProjections.onChanged((session, key, value) => { + published.push({ session: session.id, key, value }) + }) + const lead = ctx.sessions.create(ROOT) + const other = ctx.sessions.create(SessionId('other-root')) + expect(ctx.sessionProjections.snapshot(lead).values['agentTeam']).toEqual({ + members: [{ id: ROOT, name: 'lead', role: 'lead', phase: 'active' }], + tasks: [], + }) + + lead.append('team/task', taskEvent.data) + expect(published).toHaveLength(1) + expect(published[0]).toMatchObject({ + session: ROOT, + key: 'agentTeam', + value: { tasks: [{ id: 'task-1', ready: true }] }, + }) + + lead.append('team/message/queued', queuedEvent.data) + lead.append('turn/start', { turn: 1 }) + expect(published).toHaveLength(1) + + other.append('team/task', { version: 2, teamId: TeamId(other.id), task: task() }) + expect(published).toHaveLength(2) + expect(published[1]).toMatchObject({ + session: other.id, + value: { members: [{ id: other.id, name: 'lead', role: 'lead', phase: 'active' }] }, + }) + expect(ctx.sessionProjections.snapshot(lead).values['agentTeam']).toMatchObject({ + tasks: [{ id: 'task-1' }], + }) + }) + }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 87d63353ed..15c227b303 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -30,9 +30,9 @@ afterEach(() => { /** Detached durable Team read through the same projection definition as the service. */ function durable(agent: Agent): { - members: TeamMemberSnapshot[] - tasks: TeamTaskSnapshot[] - pendingMessages: TeamMessageSnapshot[] + members: readonly TeamMemberSnapshot[] + tasks: readonly TeamTaskSnapshot[] + pendingMessages: readonly TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 5965f254f5..77f7ef28a4 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 253c243a680a989988ea65e553c14eff9a699155 -README.zh.md: 143c855b8b8a45a7d3777700aefa6a378401f20d +README.md: d08b19f19849a1da517a980adf427bdd6fddcd76 +README.zh.md: 91137d2d527c87200fe6720079d639695ac7033e diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 253c243a68..d08b19f198 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads the Lead Session's `agentTeam` projection from the shared Session store, where Host projection frames keep it current without a refresh control, and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -29,13 +29,13 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline when the shared store has none; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. ----- @@ -45,14 +45,14 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso
Implementation internals — click to expand -The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations. +The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | Generated Remote, locale, navigation, and slot registrations | -| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Roster and task-board interaction state | +| [`src/client/mount.ts`](src/client/mount.ts) | Locale, projection-read, navigation, and slot registrations | +| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Projection-derived roster and task board with panel interaction state | | [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese panel copy | | [`src/index.ts`](src/index.ts) | Inert Host entry | @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and Remote behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. @@ -83,7 +83,8 @@ No direct effect; the Team tools and ordinary conversation submission own any la -- **Snapshot refresh** — the panel refreshes on open and explicit refresh; it has no live event subscription or mailbox timeline. +- **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. +- **Model before the first request** — a member without a durable model selection or request shows no model, and a teammate Session that is not loaded on the Host shows no model or activity until its projections arrive. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. @@ -97,4 +98,4 @@ None.
-**Runtime invariant:** No companion is published. RPC is authoritative and the package owns only one disposable slot registration. +**Runtime invariant:** No companion is published. The Host projection is authoritative and the package owns only one disposable slot registration. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 143c855b8b..91137d2d52 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它从共享 Session store 读取 Lead Session 的 `agentTeam` 投影,Host 投影 frame 使其保持最新而无需刷新控件,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -29,13 +29,13 @@ kind: "package-reference" ### 检查并导航 roster -打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,若共享 store 尚无 Lead 的投影基线则请求一次;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 ----- @@ -45,14 +45,14 @@ kind: "package-reference"
实现细节——点击展开 -Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。 +Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | 生成的 Remote、locale、导航与 slot registration | -| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Roster 与任务板交互状态 | +| [`src/client/mount.ts`](src/client/mount.ts) | locale、投影读取、导航与 slot registration | +| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | 由投影派生的 roster 与任务板及面板交互状态 | | [`src/client/locales.ts`](src/client/locales.ts) | 中英文 panel 文案 | | [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry | @@ -64,7 +64,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与 Remote 行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -83,7 +83,8 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. -- **Snapshot refresh**——panel 会在打开和显式 refresh 时刷新;它没有实时事件订阅或 mailbox timeline。 +- **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 +- **首次请求前没有 model**——没有持久模型选择或请求的成员不显示 model;未在 Host 加载的 teammate Session 在其投影到达前不显示 model 与活动。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 @@ -97,4 +98,4 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](.
-**运行时不变式:** 不发布伴生入口。RPC 是权威来源,本包只持有一个可释放的 slot 注册。 +**运行时不变式:** 不发布伴生入口。Host 投影是权威来源,本包只持有一个可释放的 slot 注册。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index cbde881879..aee71e7a99 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -29,7 +29,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", @@ -45,7 +44,6 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -56,13 +54,12 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -72,7 +69,6 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index bce5aa56e0..c9d678b050 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TeamMemberView as TeamRosterMember, + TeamMemberProjection, TeamTaskView as TeamTask, - TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, @@ -16,19 +15,20 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type TeamKey } from './locales.ts' import css from './TeamAction.module.css' -/** Generated Remote result consumed directly by the Team UI. */ -export type TeamActionResult = RemoteResult - /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - load: (sessionId: SessionId) => Promise> - openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void + /** Read the Lead Session's projection baseline when the shared store has none, or retry a failed read. */ + loadProjections: (leadSessionId: SessionId) => void + openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } /** Full props of the Team conversation-header action. */ export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale +/** Durable lifecycle overlaid with the member Session's live turn activity. */ +type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' + function failureText(error: { readonly code: string; readonly message: string }): string { return `${error.message} (${error.code})` } @@ -38,12 +38,12 @@ function statusKey(status: TeamTask['status']): TeamKey { case 'pending': return 'status.pending' case 'in_progress': return 'status.in_progress' case 'completed': return 'status.completed' - /* v8 ignore next -- Team views omit deleted task tombstones. */ + /* v8 ignore next -- the Team projection omits deleted task tombstones. */ case 'deleted': return 'status.completed' } } -function memberStatusKey(status: TeamRosterMember['status']): TeamKey { +function memberStatusKey(status: MemberStatus): TeamKey { switch (status) { case 'running': return 'memberStatus.running' case 'inactive': return 'memberStatus.inactive' @@ -52,7 +52,7 @@ function memberStatusKey(status: TeamRosterMember['status']): TeamKey { } } -function memberDotState(status: TeamRosterMember['status']): StateDotState { +function memberDotState(status: MemberStatus): StateDotState { switch (status) { case 'running': case 'provisioning': return 'ongoing' @@ -66,18 +66,16 @@ function taskDotState(task: TeamTask): StateDotState { case 'pending': return task.ready ? 'idle' : 'warning' case 'in_progress': return 'ongoing' case 'completed': return 'done' - /* v8 ignore next -- Team views omit deleted task tombstones. */ + /* v8 ignore next -- the Team projection omits deleted task tombstones. */ case 'deleted': return 'idle' } } -/** Render the Team roster and read-only task board. */ +/** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ export function TeamAction({ - sessionId, load, openTeammate, t, + sessionId, useSession, useSessions, useSessionStatus, loadProjections, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) - const [loading, setLoading] = useState(false) - const [view, setView] = useState(null) const [error, setError] = useState(null) const rootRef = useRef(null) const triggerRef = useRef(null) @@ -87,18 +85,24 @@ export function TeamAction({ }) const positioned = position !== null useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) - const sessionRef = useRef(sessionId) - const refreshGeneration = useRef(0) - sessionRef.current = sessionId + + const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const projections = useSessions(state => state.projectionsBySession) + const summaries = useSessions(state => state.byId) + const statuses = useSessionStatus(snapshot => snapshot) + const projection = projections[leadSessionId] + const team = projection?.values.agentTeam + const readError = team === undefined && projection?.state === 'error' ? projection.error : null useEffect(() => { - refreshGeneration.current += 1 setOpen(false) - setLoading(false) - setView(null) setError(null) }, [sessionId]) + useEffect(() => { + if (open) loadProjections(leadSessionId) + }, [open, leadSessionId, loadProjections]) + useLayoutEffect(() => { if (open && positioned) panelRef.current?.focus() }, [open, positioned]) @@ -108,22 +112,15 @@ export function TeamAction({ triggerRef.current?.focus() } - const refresh = useCallback(async (): Promise => { - const requestedSession = sessionId - const generation = ++refreshGeneration.current - setLoading(true) - const result = await load(requestedSession) - if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return - setLoading(false) - if (result.ok) { - setView(result.value) - setError(null) - } else { - setError(failureText(result.error)) - } - }, [load, sessionId]) + const memberStatus = (member: TeamMemberProjection): MemberStatus => { + if (member.phase === 'failed') return 'failed' + if (member.phase === 'provisioning') return 'provisioning' + return (statuses.get(member.id)?.running ?? summaries[member.id]?.running) === true ? 'running' : 'inactive' + } + const memberModel = (member: TeamMemberProjection): string | undefined => + projections[member.id]?.values.modelSelection?.next?.model - const teammates = view?.members.filter(member => member.role === 'teammate') ?? [] + const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] return (
{ @@ -142,11 +139,7 @@ export function TeamAction({ className={css.trigger} aria-haspopup="dialog" aria-expanded={open} - onClick={() => { - const next = !open - setOpen(next) - if (next) void refresh() - }} + onClick={() => { setOpen(!open) }} > {t('trigger')} @@ -165,12 +158,6 @@ export function TeamAction({
{t('trigger')} - {loading && view !== null && ( - - )} - @@ -178,44 +165,60 @@ export function TeamAction({ {error !== null && (
{error}
)} - {loading && view === null && ( + {readError !== null && ( +
+ + {failureText(readError)} + +
+ )} + {team === undefined && readError === null && (
{t('loading')}
)} - {view !== null && ( + {team !== undefined && ( <> + {team.failure !== undefined && ( +
{t('failure', { message: team.failure })}
+ )}

{t('roster')}

- {view.members.map(member => ( - - ))} + {team.members.map((member) => { + const status = memberStatus(member) + const model = memberModel(member) + return ( + + ) + })}

{t('tasks')}

- {view.tasks.length === 0 &&
{t('empty')}
} + {team.tasks.length === 0 &&
{t('empty')}
}
- {view.tasks.map(task => ( + {team.tasks.map(task => (
{task.subject} diff --git a/packages/experimental/client-ui-agent-team/src/client/index.ts b/packages/experimental/client-ui-agent-team/src/client/index.ts index 5e2093105e..a4896c82af 100644 --- a/packages/experimental/client-ui-agent-team/src/client/index.ts +++ b/packages/experimental/client-ui-agent-team/src/client/index.ts @@ -1,14 +1,16 @@ -/** Browser entry binding the generated Team Remote artifact to its Client UI. */ +/** Browser entry registering the Agent Teams conversation-header action. */ -import agentTeamsRemote from '@deepseek-ai/dsh-experimental-agent-team/remote' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { mountAgentTeamUi } from './mount.ts' +import { registerAgentTeamUi } from './mount.ts' export { inject } from './mount.ts' -export type { TeamActionInjected, TeamActionProps, TeamActionResult } from './TeamAction.tsx' +export type { TeamActionInjected, TeamActionProps } from './TeamAction.tsx' export type { TeamKey } from './locales.ts' -/** Mount the generated Team Remote contribution and its browser UI. */ -export async function apply(ctx: ClientContext): Promise<() => Promise> { - return await mountAgentTeamUi(ctx, agentTeamsRemote) +/** + * Register the Team locale dictionaries and header action on the Client Context. + * @param ctx - Client Context with the declared `inject` services available. + */ +export function apply(ctx: ClientContext): void { + registerAgentTeamUi(ctx) } diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 9cef05d89c..8d3c45d513 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -6,9 +6,10 @@ export const NS = 'agent-team' /** Simplified Chinese dictionary and key source. */ export const zh = { trigger: 'Agent Team', - refresh: '刷新 Team', close: '关闭', + retry: '重试读取 Team', loading: '正在加载 Team…', + failure: 'Team 持久记录无效:{message}', empty: '还没有共享任务', roster: '成员', tasks: '共享任务', @@ -35,9 +36,10 @@ export type TeamKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { trigger: 'Agent Team', - refresh: 'Refresh Team', close: 'Close', + retry: 'Retry reading Team', loading: 'Loading Team…', + failure: 'Invalid persisted Team record: {message}', empty: 'No shared tasks yet', roster: 'Members', tasks: 'Shared tasks', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index ad9648e322..d7e9083307 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -1,22 +1,14 @@ -/** Source-safe Agent Teams browser registration and Remote mount lifecycle. */ +/** Source-safe Agent Teams browser registration. */ -import type { - TeamMemberView as TeamRosterMember, - TeamView, -} from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' +import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' -import { - TeamAction, type TeamActionInjected, type TeamActionResult, -} from './TeamAction.tsx' +import { TeamAction, type TeamActionInjected } from './TeamAction.tsx' import { en, NS, zh, type TeamKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -26,10 +18,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required browser services for RPC, navigation, slots, and localized copy. */ -export const inject = ['sessions', 'uiWorkspace', 'remote', 'slots', 'locale'] +/** Required browser services for projection reads, navigation, slots, and localized copy. */ +export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale'] -function registerUi(ctx: ClientContext): void { +/** + * Register the Team locale dictionaries and the conversation-header action. + * The panel reads the Lead Session's `agentTeam` projection from the shared + * Session store; this registration performs no Team RPC. + * @param ctx - Client Context carrying the injected navigation, locale, slot, and Session services. + */ +export function registerAgentTeamUi(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'client-ui-agent-team: dictionaries') const sessions = ctx.sessions const leadSessionId = (sessionId: SessionId): SessionId => { @@ -38,10 +36,10 @@ function registerUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - async load(sessionId): Promise> { - return await ctx.remote.agentTeams.view(leadSessionId(sessionId)) + loadProjections(leadId: SessionId): void { + void sessions.refreshProjections(leadId) }, - openTeammate(sessionId: SessionId, member: TeamRosterMember): void { + openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return const parentSessionId = leadSessionId(sessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return @@ -64,28 +62,3 @@ function registerUi(ctx: ClientContext): void { }, TeamAction), ) } - -/** - * Mount one generated Team Remote contribution, then register its browser UI. - * @param ctx - Client Context carrying navigation, locale, slot, and Remote services. - * @param contribution - generated Team descriptors selected by the browser entry. - * @returns disposer for both the UI registrations and Remote namespace. - */ -export async function mountAgentTeamUi( - ctx: ClientContext, - contribution: TypertRemoteContribution, -): Promise<() => Promise> { - const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['sessions', 'uiWorkspace', 'remote.agentTeams', 'slots', 'locale'], registerUi) - try { - await ui - } catch (error) { - await ui.dispose() - await disposeRemote() - throw error - } - return async () => { - await ui.dispose() - await disposeRemote() - } -} diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index a153ec2870..6e5bcaefb8 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,78 +1,23 @@ -import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import type { TeamMemberView as TeamRosterMember } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client' import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx' -import { inject, mountAgentTeamUi } from '../src/client/mount.ts' +import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' const SESSION = 'team-session' as SessionId const CHILD = 'team-child' as SessionId -const REMOTE: TypertRemoteContribution = { - package: '@deepseek-ai/dsh-experimental-agent-team', - descriptors: [], -} +const LEAD: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } +const WORKER: TeamMemberProjection = { id: CHILD, name: 'worker', role: 'teammate', phase: 'active' } -async function bench(options: { - addressed?: boolean - registrationFailure?: boolean - remoteFailure?: boolean - catalog?: 'missing' | 'empty' -} = {}) { +async function bench(options: { addressed?: boolean } = {}) { const ctx = new Context() - const calls: { method: string; args: unknown[] }[] = [] - const task = { - id: 'task-1', - revision: 1, subject: 'Task', description: 'Description', status: 'pending' as const, - blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], - } - class RemoteService extends Service { - readonly disposeMount = vi.fn(() => Promise.resolve()) - readonly mount = vi.fn((_contribution: unknown) => Promise.resolve(this.disposeMount)) - - constructor(serviceCtx: Context) { - super(serviceCtx, 'remote') - } - - $mount(contribution: unknown): Promise<() => Promise> { - return this.mount(contribution) - } - } - const remote = new RemoteService(ctx) - const failure = { - ok: false as const, - error: new RemoteError('gateway/internal', 'offline', {}), - } - const view = { - members: [{ - id: SESSION, name: 'lead', role: 'lead' as const, status: 'inactive' as const, diagnostics: [], - }], tasks: [task], - } - ctx.provide('remote.agentTeams', { - view: (...args: unknown[]) => { - calls.push({ method: 'agentTeams/view', args }) - return Promise.resolve(options.remoteFailure - ? failure - : { ok: true as const, value: view }) - }, - }) const navigation: unknown[] = [] let mainSessionId = options.addressed === true ? CHILD : SESSION - const projectionsBySession = options.catalog === 'missing' - ? {} - : { - [SESSION]: { state: 'ready' as const, error: null, values: { subagentCatalog: options.catalog === 'empty' ? [] : [{ createdAt: 1, id: CHILD, - mode: 'continuable' as const, - label: 'worker' as const, - }] } }, - } ctx.provide('sessions', { - list: { getSnapshot: () => ({ projectionsBySession }) }, binding: (id: SessionId) => options.addressed === true && id === CHILD ? { session: { getSnapshot: () => ({ subagent: { @@ -106,153 +51,66 @@ async function bench(options: { name: 'root', children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' } }, } as never, () => null) - if (options.registrationFailure === true) { - vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot registration failed') }) - } - const fiber = options.registrationFailure === true - ? ctx.plugin({ apply() {} }) - : ctx.plugin({ inject: [...inject], apply: clientCtx => mountAgentTeamUi(clientCtx, REMOTE) }) - const activation: Promise = options.registrationFailure === true - ? mountAgentTeamUi(ctx, REMOTE).catch((error: unknown) => error) - : fiber.await() - if (options.registrationFailure !== true) { - await activation - } else { - await fiber.await() - } + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() const entry = () => ctx.slots.entries('conversation.session.header.actions') .find(candidate => candidate.component === TeamAction) + const actions = () => (entry()!.inject as unknown as () => TeamActionInjected)() return { ctx, fiber, - activation, - calls, navigation, - remote, entry, + actions, collapseHeader, select: (sessionId: SessionId) => { mainSessionId = sessionId }, } } describe('ui-team browser plugin', () => { - it('registers one disposable header action with a read-only RPC-backed task board', async () => { + it('registers one disposable header action without a Team Remote namespace', async () => { const b = await bench() - expect(inject).toEqual(['sessions', 'uiWorkspace', 'remote', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) expect(b.entry()).toMatchObject({ options: { id: 'agent-team', order: 20 }, locale: 'agent-team', }) - expect(b.remote.mount).toHaveBeenCalledOnce() - expect(b.remote.mount).toHaveBeenCalledWith(REMOTE) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - expect((await actions.load(SESSION)).ok).toBe(true) - expect(b.calls).toEqual([{ method: 'agentTeams/view', args: [SESSION] }]) + const t = b.ctx.locale.bind('agent-team') + expect(t('trigger')).toBe('Agent Team') - actions.openTeammate(SESSION, { - id: SESSION, - name: 'lead', - role: 'lead', - status: 'inactive', - diagnostics: [], - }) - expect(b.navigation).toEqual([]) + b.actions().loadProjections(SESSION) + expect(b.navigation).toEqual([['refresh', SESSION]]) + b.actions().openTeammate(SESSION, LEAD) + expect(b.navigation).toEqual([['refresh', SESSION]]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() - expect(b.remote.disposeMount).toHaveBeenCalledOnce() + expect(t('empty')).toBe('empty') }) - it('unmounts the Remote contribution when later Client registration fails', async () => { - const b = await bench({ registrationFailure: true }) - await expect(b.activation).resolves.toMatchObject({ message: 'slot registration failed' }) - expect(b.remote.mount).toHaveBeenCalledOnce() - expect(b.remote.disposeMount).toHaveBeenCalledOnce() - }) - - it('returns Remote carrier failures unchanged', async () => { - const view = await bench({ remoteFailure: true }) - const viewActions = (view.entry()!.inject as unknown as () => TeamActionInjected)() - await expect(viewActions.load(SESSION)).resolves.toMatchObject({ - ok: false, - error: { code: 'gateway/internal', message: 'offline' }, - }) - - }) - - it('opens a continuable teammate address without refreshing the parent catalog', async () => { + it('opens a continuable teammate address without touching the parent catalog', async () => { const b = await bench() - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - const member: TeamRosterMember = { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - } - actions.openTeammate(SESSION, member) + b.actions().openTeammate(SESSION, WORKER) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) - it('routes Team actions from an addressed teammate conversation back through its Lead', async () => { + it('routes teammate navigation from an addressed teammate conversation back through its Lead', async () => { const b = await bench({ addressed: true }) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - await actions.load(CHILD) - actions.openTeammate(CHILD, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) - expect(b.calls[0]).toEqual({ method: 'agentTeams/view', args: [SESSION] }) + b.actions().openTeammate(CHILD, WORKER) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) it('does not open a teammate from a conversation outside the main view', async () => { const b = await bench() - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() b.select('other-session' as SessionId) - actions.openTeammate(SESSION, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) + b.actions().openTeammate(SESSION, WORKER) expect(b.navigation).toEqual([]) }) - it('opens a teammate when the parent catalog is missing or empty', async () => { - for (const catalog of ['missing', 'empty'] as const) { - const b = await bench({ catalog }) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - actions.openTeammate(SESSION, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) - expect(b.navigation).toEqual([ - ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], - ]) - await b.fiber.dispose() - } - }) - it('re-registers after the conversation header slot is collapsed and declared again', async () => { const b = await bench() expect(b.entry()).toBeDefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 82a90c7e02..90aecc495b 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -1,22 +1,25 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TeamTaskId, TeamTaskView as TeamTask, TeamView, + TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { - TeamAction, type TeamActionInjected, type TeamActionProps, type TeamActionResult, -} from '../src/client/TeamAction.tsx' +import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx' import { zh } from '../src/client/locales.ts' afterEach(cleanup) const SESSION = 'lead' as SessionId +const WORKER = 'worker-id' as SessionId const TASK_1 = 'task-1' as TeamTaskId +const TASK_2 = 'task-2' as TeamTaskId const task: TeamTask = { id: TASK_1, revision: 1, @@ -29,214 +32,231 @@ const task: TeamTask = { ready: false, writeScopeWarnings: ['write scopes overlap with task-2'], } -const view: TeamView = { - members: [ - { id: SESSION, name: 'lead', role: 'lead', status: 'inactive', model: 'model-a', diagnostics: [] }, - { - id: 'worker-id' as SessionId, - name: 'worker', - role: 'teammate', - status: 'inactive', - model: 'model-a', - diagnostics: [], - }, - ], - tasks: [task], +const lead: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } +const worker: TeamMemberProjection = { + id: WORKER, name: 'worker', role: 'teammate', phase: 'active', description: 'worker', provider: 'spawn', context: 'fresh', } +const team: TeamProjection = { members: [lead, worker], tasks: [task] } -function remoteFailure(message: string): TeamActionResult { - return { ok: false, error: new RemoteError('gateway/internal', message, {}) } -} +type Projections = SessionListState['projectionsBySession'] -function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps { - return { +function bench(options: { + projections?: Projections + sessionId?: SessionId + parentSessionId?: SessionId + statuses?: SessionStatusSnapshot + running?: Record +} = {}) { + const sessionId = options.sessionId ?? SESSION + const byId = Object.fromEntries(Object.entries(options.running ?? {}).map(([id, running]) => [id, { sessionId: id, running }])) + const sessions = createSnapshotStore({ + ids: Object.keys(byId), byId, phase: 'ready', + projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: team } } }, + } as unknown as SessionListState) + const statuses = createSnapshotStore(options.statuses ?? new Map()) + const session = createSnapshotStore({ sessionId, - ...actions, + subagent: options.parentSessionId === undefined + ? null + : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, + } as unknown as SessionSnapshot) + const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } + const props = { + sessionId, + useSession: bindSnapshotSelector(session), + useSessions: bindSnapshotSelector(sessions), + useSessionStatus: bindSnapshotSelector(statuses), + ...injected, t: makeTranslate(zh, commonZh), } as unknown as TeamActionProps + return { props, injected, sessions, statuses, session } } -function actions(overrides: Partial = {}): TeamActionInjected { - return { - load: () => Promise.resolve({ ok: true, value: view }), - openTeammate: () => {}, - ...overrides, - } +function openPanel(): void { + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) +} + +function setProjectionSnapshot( + sessions: ReturnType['sessions'], + sessionId: SessionId, + snapshot: Projections[SessionId], +): void { + act(() => { + const current = sessions.getSnapshot() + sessions.set({ ...current, projectionsBySession: { ...current.projectionsBySession, [sessionId]: snapshot } }) + }) +} + +function setProjection(sessions: ReturnType['sessions'], sessionId: SessionId, value: TeamProjection): void { + setProjectionSnapshot(sessions, sessionId, { state: 'ready', error: null, values: { agentTeam: value } }) } describe('TeamAction', () => { - it('ignores a stale Team load after the conversation switches sessions', async () => { - const nextSession = 'next-lead' as SessionId - const firstLoad = Promise.withResolvers<{ ok: true; value: TeamView }>() - const nextView: TeamView = { - ...view, - members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'inactive', diagnostics: [] }], - tasks: [{ ...task, id: 'task-next' as TeamTaskId, subject: 'Next session task' }], - } - const load = vi.fn((sessionId: SessionId) => sessionId === SESSION - ? firstLoad.promise - : Promise.resolve({ ok: true as const, value: nextView })) - const injected = actions({ load }) - const rendered = render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await waitFor(() => { expect(load).toHaveBeenCalledWith(SESSION) }) - - rendered.rerender() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('Next session task')).toBeTruthy() - firstLoad.resolve({ ok: true, value: view }) - await Promise.resolve() - - await waitFor(() => { - expect(screen.getByText('Next session task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() - }) - }) - - it('loads roster/task diagnostics on open and navigates a healthy teammate', async () => { - const openTeammate = vi.fn() - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - const worker = await screen.findByRole('button', { name: /worker/u }) + it('renders the Lead projection and applies later projection frames without any user action', async () => { + const b = bench() + render() + expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('1') + openPanel() + expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - fireEvent.click(worker) - await waitFor(() => { expect(openTeammate).toHaveBeenCalledWith(SESSION, view.members[1]) }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() + + setProjection(b.sessions, SESSION, { + members: [lead, worker, { id: 'worker-b' as SessionId, name: 'worker-b', role: 'teammate', phase: 'provisioning' }], + tasks: [task, { ...task, id: TASK_2, subject: 'Pushed task', status: 'pending', ready: true, writeScopeWarnings: [] }], + }) + expect(screen.getByText('Pushed task')).toBeTruthy() + expect(screen.getByRole('button', { name: /worker-b/u })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') }) - it('keeps only the newest overlapping refresh for one session', async () => { - const older = Promise.withResolvers>() - const newer = Promise.withResolvers>() - const newestView = { - ...view, - tasks: [{ ...task, id: 'newest-task' as TeamTaskId, subject: 'Newest task' }], - } - const load = vi.fn() - .mockResolvedValueOnce({ ok: true, value: view }) - .mockImplementationOnce(() => older.promise) - .mockImplementationOnce(() => newer.promise) - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByText('Implement runtime') + it('overlays live Session status and the durable model selection on roster rows', () => { + const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]]) + const b = bench({ + statuses, + running: { [SESSION]: true }, + projections: { + [SESSION]: { + state: 'ready', error: null, + values: { agentTeam: team, modelSelection: { lastUsed: null, next: { provider: 'p', model: 'lead-model' } } }, + }, + [WORKER]: { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: { provider: 'p', model: 'worker-model' }, next: { provider: 'p', model: 'worker-model' } } }, + }, + }, + }) + render() + openPanel() + expect(screen.getByRole('button', { name: `lead${zh['memberStatus.running']} · ${zh.model}: lead-model` })).toBeTruthy() + const row = screen.getByRole('button', { name: `worker${zh['memberStatus.running']} · ${zh.model}: worker-model` }) + expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() - const refresh = screen.getByRole('button', { name: zh.refresh }) - fireEvent.click(refresh) - expect(screen.getByRole('status', { name: zh.loading })).toBeTruthy() - fireEvent.click(refresh) - newer.resolve({ ok: true, value: newestView }) - expect(await screen.findByText('Newest task')).toBeTruthy() - older.resolve({ ok: true, value: view }) - await Promise.resolve() + act(() => { b.statuses.set(new Map([[WORKER, { running: false, pendingInteraction: undefined, completionUnread: false }]])) }) + expect(screen.getByRole('button', { name: /^worker未运行/u })).toBeTruthy() - expect(screen.getByText('Newest task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() + act(() => { + b.statuses.set(new Map()) + b.sessions.update((draft) => { draft.byId[WORKER] = { sessionId: WORKER, running: true } as never }) + }) + expect(screen.getByRole('button', { name: /^worker运行中/u })).toBeTruthy() }) - it('renders roster/task state variants and contains navigation, refresh, and close actions', async () => { + it('reads the Lead projection from an addressed teammate conversation', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION }) + render() + openPanel() + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) + }) + + it('shows loading until the projection arrives and a retryable read failure', () => { + const b = bench({ projections: {} }) + render() + openPanel() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + + setProjectionSnapshot(b.sessions, SESSION, { state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {} }) + expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') + expect(screen.queryByRole('status')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: zh.retry })) + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) + expect(screen.getByRole('status').textContent).toBe(zh.loading) + + setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) + expect(screen.getByText(zh.empty)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('surfaces a Team projection failure beside the last valid state', () => { + const b = bench({ + projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } }, + }) + render() + openPanel() + expect(screen.getByRole('alert').textContent).toBe('Team 持久记录无效:revision is not contiguous') + expect(screen.getByText('Implement runtime')).toBeTruthy() + }) + + it('renders roster/task state variants and reports navigation failures', () => { const { ownerName: _ownerName, ...unownedTask } = task - const richView: TeamView = { - ...view, - members: [ - view.members[0]!, - { ...view.members[1]!, status: 'running' }, - { - id: 'failed-id' as SessionId, - name: 'failed-worker', - role: 'teammate', - status: 'failed', - diagnostics: ['provider failed'], + const b = bench({ + projections: { + [SESSION]: { + state: 'ready', error: null, + values: { + agentTeam: { + members: [ + lead, + worker, + { id: 'failed-id' as SessionId, name: 'failed-worker', role: 'teammate', phase: 'failed', error: 'provider failed' }, + { id: 'provisioning-id' as SessionId, name: 'provisioning-worker', role: 'teammate', phase: 'provisioning' }, + ], + tasks: [ + { ...unownedTask, id: 'ready-task' as TeamTaskId, status: 'pending', ready: true }, + { ...unownedTask, id: 'blocked-task' as TeamTaskId, status: 'pending', ready: false, blockedBy: [TASK_1] }, + { ...task, id: 'completed-task' as TeamTaskId, status: 'completed', ownerName: 'worker' }, + ], + }, + }, }, - { - id: 'provisioning-id' as SessionId, - name: 'provisioning-worker', - role: 'teammate', - status: 'provisioning', - diagnostics: [], - }, - ], - tasks: [ - { ...unownedTask, id: 'ready-task' as TeamTaskId, status: 'pending', ready: true }, - { ...unownedTask, id: 'blocked-task' as TeamTaskId, status: 'pending', ready: false, blockedBy: [TASK_1] }, - { ...task, id: 'completed-task' as TeamTaskId, status: 'completed' }, - ], - } - const load = vi.fn(() => Promise.resolve({ ok: true as const, value: richView })) - const openTeammate = vi.fn(() => { throw new Error('navigation failed') }) - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('provider failed')).toBeTruthy() + }, + }) + b.injected.openTeammate = vi.fn(() => { throw new Error('navigation failed') }) + render() + openPanel() + expect(screen.getByText('provider failed')).toBeTruthy() expect(screen.getByText(zh.ready)).toBeTruthy() expect(screen.getByText(zh.blocked)).toBeTruthy() + expect(screen.getAllByText('Owner: 未分配')).toHaveLength(2) + expect(screen.getByText('Owner: worker')).toBeTruthy() const failedMember = screen.getByRole('button', { name: /failed-worker/u }) const provisioningMember = screen.getByRole('button', { name: /provisioning-worker/u }) expect(failedMember.disabled).toBe(true) expect(failedMember.querySelector('[data-state="error"]')).not.toBeNull() expect(provisioningMember.disabled).toBe(true) expect(provisioningMember.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(screen.getByRole('button', { name: /^lead/u }).disabled).toBe(true) const tasks = [...document.querySelectorAll('article')] expect(tasks.map(card => card.querySelector('[data-state]')?.getAttribute('data-state'))) .toEqual(['idle', 'warning', 'done']) + for (const card of tasks) expect(card.querySelector('button, input, select, textarea')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /^worker运行中/u })) - expect(await screen.findByText('Error: navigation failed')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: zh.refresh })) - await waitFor(() => { expect(load).toHaveBeenCalledTimes(2) }) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(screen.queryByRole('dialog')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByRole('dialog') + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert').textContent).toBe('Error: navigation failed') fireEvent.click(screen.getByRole('button', { name: zh.close })) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) }) - it('shows load failures', async () => { - render( Promise.resolve(remoteFailure('load failed')), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'load failed (gateway/internal)') + it('closes the panel and clears a navigation failure when the conversation switches sessions', () => { + const b = bench() + b.injected.openTeammate = vi.fn(() => { throw new Error('navigation failed') }) + const rendered = render() + openPanel() + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert')).toBeTruthy() + + const next = bench({ sessionId: 'next-lead' as SessionId, projections: {} }) + rendered.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + openPanel() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') }) - it('shows an empty task board without a create action', async () => { - render( Promise.resolve({ ok: true, value: { members: [], tasks: [] } }), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText(zh.empty)).toBeTruthy() - expect(screen.queryByRole('button', { name: /新建任务/u })).toBeNull() - expect(screen.queryByRole('textbox')).toBeNull() - }) - - it('displays task ownership and status without mutation controls', async () => { - const { ownerName: _ownerName, ...unowned } = task - const tasks: TeamTask[] = [ - { ...unowned, status: 'pending', ready: true }, - { ...task, id: 'task-2' as TeamTaskId }, - { ...task, id: 'task-3' as TeamTaskId, status: 'completed', ownerName: 'worker' }, - ] - render( Promise.resolve({ ok: true, value: { ...view, tasks } }), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findAllByRole('article') - expect(screen.getByText('Owner: 未分配')).toBeTruthy() - expect(screen.getByText('Owner: lead')).toBeTruthy() - expect(screen.getByText('Owner: worker')).toBeTruthy() - expect(screen.getByText(zh['status.pending'])).toBeTruthy() - expect(screen.getByText(zh['status.in_progress'])).toBeTruthy() - expect(screen.getByText(zh['status.completed'])).toBeTruthy() - expect(screen.queryByRole('button', { name: /新建任务|编辑|完成|重开|删除/u })).toBeNull() - expect(screen.queryByRole('combobox')).toBeNull() - expect(screen.queryByRole('textbox')).toBeNull() - for (const card of screen.getAllByRole('article')) { - expect(card.querySelector('button, input, select, textarea')).toBeNull() - } - }) - it('keeps panel interactions open and dismisses on outside pointer or Escape', async () => { - const rendered = render() + it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { + const b = bench() + const rendered = render() const trigger = screen.getByRole('button', { name: /Agent Team/u }) fireEvent.click(trigger) - const panel = await screen.findByRole('dialog') + const panel = screen.getByRole('dialog') expect(rendered.container.contains(panel)).toBe(false) expect(document.activeElement).toBe(panel) fireEvent.pointerDown(panel) @@ -246,22 +266,22 @@ describe('TeamAction', () => { fireEvent.pointerDown(document.body) expect(screen.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) - await screen.findByText('Implement runtime') - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Enter' }) expect(screen.queryByRole('dialog')).not.toBeNull() - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(trigger) fireEvent.keyDown(trigger, { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() }) - it('keeps focus within the trigger or panel and closes when focus moves elsewhere', async () => { - render(<>) + it('keeps focus within the trigger or panel and closes when focus moves elsewhere', () => { + const b = bench() + render(<>) const trigger = screen.getByRole('button', { name: /Agent Team/u }) fireEvent.click(trigger) - const panel = await screen.findByRole('dialog') - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.refresh }) }) + const panel = screen.getByRole('dialog') + fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.close }) }) expect(screen.getByRole('dialog')).toBe(panel) fireEvent.blur(panel, { relatedTarget: trigger }) expect(screen.getByRole('dialog')).toBe(panel) @@ -270,5 +290,4 @@ describe('TeamAction', () => { fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: 'Outside' }) }) expect(screen.queryByRole('dialog')).toBeNull() }) - }) diff --git a/packages/experimental/client-ui-agent-team/tsconfig.json b/packages/experimental/client-ui-agent-team/tsconfig.json index c26bb4b7b6..206b674bb8 100644 --- a/packages/experimental/client-ui-agent-team/tsconfig.json +++ b/packages/experimental/client-ui-agent-team/tsconfig.json @@ -8,16 +8,15 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../agent-team" }, - { "path": "../../api/remotes/tsconfig.client.json" }, { "path": "../../api/session-controller/tsconfig.client.json" }, { "path": "../../client/locale" }, + { "path": "../../client/store" }, { "path": "../../client/ui-conversation" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, { "path": "../../client/ui-session" }, { "path": "../../client/ui-slots" }, { "path": "../../client/ui-workspace" }, - { "path": "../../core/session" }, - { "path": "../../typert/protocol" } + { "path": "../../core/session" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62104b7db4..c2e6a4d236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6805,15 +6805,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../../client/store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -6841,9 +6841,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol '@testing-library/react': specifier: ^16.1.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From 38dbb56237b9347a7b97d0e4c51c46a64fe24657 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 16:45:40 +0800 Subject: [PATCH 02/25] test(agent-team): type Team panel fixtures without unknown casts and regenerate catalogs The static CI gate rejected four new assertions to unknown in the rewritten Team panel specs. Build the Session list, Session snapshot, and injected-callback fixtures as typed values, prune the retired baseline fingerprints, and regenerate the module graph, config, and persistence catalogs for the removed Remote dependency edges and the shifted source lines. --- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 4 +-- docs/module-graph.zh.md | 4 +-- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 20 +++++------ docs/persistence-catalog.zh.md | 20 +++++------ docs/persistence-schema.json | 12 +++---- .../tests/browser-plugin.client.spec.ts | 12 ++++++- .../tests/team-action.client.spec.tsx | 35 ++++++++++++++----- scripts/no-unknown-casts.baseline.json | 7 ---- 11 files changed, 70 insertions(+), 54 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ce0bc0a4f..fd150c7e69 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -627,7 +627,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ec94e38ea0..69049de561 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 936324af096fa6b17e260e9d9cd6d03a34fb9bd7 -module-graph.zh.md: c68f95c65af7df50dbfd61bf79d754c137c7f709 +module-graph.md: e6e025766974c2bb5286bfeb2333044f83b19a25 +module-graph.zh.md: d092387c3a04d930042357baade4d66407a26b2c diff --git a/docs/module-graph.md b/docs/module-graph.md index 936324af09..e6e0257669 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1259,7 +1259,6 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation @@ -1270,7 +1269,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_client_ui_voice_input --> pkg_api_gateway pkg_experimental_client_ui_voice_input --> pkg_api_remotes pkg_experimental_client_ui_voice_input --> pkg_api_session_controller @@ -1622,7 +1620,7 @@ flowchart TD | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c68f95c65a..d092387c3a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1261,7 +1261,6 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation @@ -1272,7 +1271,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_client_ui_voice_input --> pkg_api_gateway pkg_experimental_client_ui_voice_input --> pkg_api_remotes pkg_experimental_client_ui_voice_input --> pkg_api_session_controller @@ -1624,7 +1622,7 @@ flowchart TD | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 42b628640b..0143723b78 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 30fc6dc277bdbdaf5330740a8a291101358dbaa2 -persistence-catalog.zh.md: fff627b8ff3a8c1c97f7793b0abca7008f395eb0 +persistence-catalog.md: e9b0fd69f14d44e992146d003f9d1bd031ff4e3d +persistence-catalog.zh.md: 699ca6996ba87c12b44e99a3cdd8bed6a8a5e196 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 30fc6dc277..e9b0fd69f1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index fff627b8ff..699ca6996b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index df74feb6a1..f0f2891aea 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:216" + "packages/experimental/agent-team/src/types.ts:247" ] }, { @@ -31770,7 +31770,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSource" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:115" + "packages/experimental/agent-team/src/types.ts:146" ] }, { @@ -33739,7 +33739,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:106" + "packages/experimental/agent-team/src/types.ts:137" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:212" + "packages/experimental/agent-team/src/types.ts:243" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:210" + "packages/experimental/agent-team/src/types.ts:241" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:214" + "packages/experimental/agent-team/src/types.ts:245" ] }, { diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 6e5bcaefb8..d63394fd64 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -55,7 +55,17 @@ async function bench(options: { addressed?: boolean } = {}) { await fiber.await() const entry = () => ctx.slots.entries('conversation.session.header.actions') .find(candidate => candidate.component === TeamAction) - const actions = () => (entry()!.inject as unknown as () => TeamActionInjected)() + const actions = (): TeamActionInjected => { + const injected = entry()!.inject!() + const { loadProjections, openTeammate } = injected + if (typeof loadProjections !== 'function' || typeof openTeammate !== 'function') { + throw new Error('Team header action lacks its injected callbacks') + } + return { + loadProjections: loadProjections as TeamActionInjected['loadProjections'], + openTeammate: openTeammate as TeamActionInjected['openTeammate'], + } + } return { ctx, fiber, diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 90aecc495b..a8f8713a5e 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionListState, SessionSnapshot, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -40,35 +40,52 @@ const team: TeamProjection = { members: [lead, worker], tasks: [task] } type Projections = SessionListState['projectionsBySession'] +function summary(id: SessionId, running: boolean): SessionSummary { + return { id, displayTitle: id, running, retainedBy: {}, blank: false, updatedAt: 0 } +} + function bench(options: { projections?: Projections sessionId?: SessionId parentSessionId?: SessionId statuses?: SessionStatusSnapshot - running?: Record + running?: Record } = {}) { const sessionId = options.sessionId ?? SESSION - const byId = Object.fromEntries(Object.entries(options.running ?? {}).map(([id, running]) => [id, { sessionId: id, running }])) + const byId: Record = {} + for (const [id, running] of Object.entries(options.running ?? {}) as [SessionId, boolean][]) byId[id] = summary(id, running) const sessions = createSnapshotStore({ - ids: Object.keys(byId), byId, phase: 'ready', + ids: Object.keys(byId) as SessionId[], byId, phase: 'ready', projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: team } } }, - } as unknown as SessionListState) + }) const statuses = createSnapshotStore(options.statuses ?? new Map()) const session = createSnapshotStore({ sessionId, + pendingSubmissions: [], + running: false, subagent: options.parentSessionId === undefined ? null : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, - } as unknown as SessionSnapshot) + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + }) const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } - const props = { + const props: TeamActionProps = { sessionId, useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(sessions), useSessionStatus: bindSnapshotSelector(statuses), ...injected, t: makeTranslate(zh, commonZh), - } as unknown as TeamActionProps + } as TeamActionProps return { props, injected, sessions, statuses, session } } @@ -138,7 +155,7 @@ describe('TeamAction', () => { act(() => { b.statuses.set(new Map()) - b.sessions.update((draft) => { draft.byId[WORKER] = { sessionId: WORKER, running: true } as never }) + b.sessions.update((draft) => { draft.byId[WORKER] = summary(WORKER, true) }) }) expect(screen.getByRole('button', { name: /^worker运行中/u })).toBeTruthy() }) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index b274bbe4fe..1c65d33a6d 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1187,13 +1187,6 @@ "019c354b579e6d5cfa63e106e3b3047817fe5a37eee532abd7ab4aa80d28676a": 1, "4777453719b1a12ac03d7405a7cc1662ac1873b8eb0016a08ccd11f27076e803": 1 }, - "packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts": { - "22baf2573ca146a7904b9ffc8b34a43692c4e042f01f43016d85443828bc88b1": 1, - "87ef7d60538b938bd8b5748cf4c0a5956ecedb860826c57d9ec789daea2f0355": 5 - }, - "packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx": { - "032fa1f91833ea216b76d2724f073741e70ad3eafebe4fbfcb166b950618e415": 1 - }, "packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts": { "154da39139a0074bad3d13a624e5fa62d1f7ce19dc8b9c28a73af3263cd7f10e": 1 }, From 552235aab6a33cdeb52f02317aed69a5bd518d27 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 21 Sep 2026 16:52:05 +0800 Subject: [PATCH 03/25] docs: align the Chinese config catalog with the regenerated source line --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1bb4e69c24..582b239257 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4ce0bc0a4f660fe3f27091012072c5799231f12b -config-catalog.zh.md: b8077cd13fff1f7c3b0a89f9f9c335a8716c18ac +config-catalog.md: fd150c7e69403e9edbcf871a9d58cdd322bfeaa3 +config-catalog.zh.md: e125bce7258fd86f84ed77332d3656c058223c02 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b8077cd13f..e125bce725 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -631,7 +631,7 @@ export interface Config { } ``` -来源:[`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) From d08aac9db27ce1d0e35298ff409262656e5fa000 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 10:46:06 +0800 Subject: [PATCH 04/25] fix(agent-team): read current projections through standard hooks --- .../client-ui-agent-team/README.i18n.yaml | 4 +-- .../client-ui-agent-team/README.md | 4 +-- .../client-ui-agent-team/README.zh.md | 4 +-- .../src/client/TeamAction.tsx | 10 ++++--- .../tests/team-action.client.spec.tsx | 29 +++++++++++++++++-- 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 77f7ef28a4..c61dc57ea4 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: d08b19f19849a1da517a980adf427bdd6fddcd76 -README.zh.md: 91137d2d527c87200fe6720079d639695ac7033e +README.md: 23617b1f3f7da511f1a1f5fa588450d0c484c94f +README.zh.md: f119df4fbd62c2a1c7ff365fdf53faa90fe3e423 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index d08b19f198..23617b1f3f 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline when the shared store has none; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's explicit projection baseline once per connection, skipping a successful prior read; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` supplies that other Session's Team projection. Other members' models also come from the shared Session values; member activity comes from `useSessionStatus` with the list summary as fallback. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 91137d2d52..f119df4fbd 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,若共享 store 尚无 Lead 的投影基线则请求一次;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,每次连接请求一次 Lead 的显式投影基线,之前读取成功则跳过;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 提供该另一个 Session 的 Team 投影。其他成员的 model 也来自共享 Session 值;成员活动来自 `useSessionStatus`,并以列表摘要为后备。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index c9d678b050..c6661bed06 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -17,7 +17,7 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Read the Lead Session's projection baseline when the shared store has none, or retry a failed read. */ + /** Request the Lead's projection baseline once per connection, or retry a failed read. */ loadProjections: (leadSessionId: SessionId) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -73,7 +73,7 @@ function taskDotState(task: TeamTask): StateDotState { /** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ export function TeamAction({ - sessionId, useSession, useSessions, useSessionStatus, loadProjections, openTeammate, t, + sessionId, useSession, useProjection, useSessions, useSessionStatus, loadProjections, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) const [error, setError] = useState(null) @@ -87,11 +87,13 @@ export function TeamAction({ useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const currentTeam = useProjection('agentTeam') + const currentModel = useProjection('modelSelection', value => value?.next?.model) const projections = useSessions(state => state.projectionsBySession) const summaries = useSessions(state => state.byId) const statuses = useSessionStatus(snapshot => snapshot) const projection = projections[leadSessionId] - const team = projection?.values.agentTeam + const team = leadSessionId === sessionId ? currentTeam : projection?.values.agentTeam const readError = team === undefined && projection?.state === 'error' ? projection.error : null useEffect(() => { @@ -118,7 +120,7 @@ export function TeamAction({ return (statuses.get(member.id)?.running ?? summaries[member.id]?.running) === true ? 'running' : 'inactive' } const memberModel = (member: TeamMemberProjection): string | undefined => - projections[member.id]?.values.modelSelection?.next?.model + member.id === sessionId ? currentModel : projections[member.id]?.values.modelSelection?.next?.model const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index a8f8713a5e..f150de9021 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -6,7 +6,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { SessionListState, SessionSnapshot, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionListState, SessionSnapshot, SessionSummary, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -77,11 +77,18 @@ function bench(options: { promptAttempted: false, awaitingFirstTurn: false, }) + const useSessions = bindSnapshotSelector(sessions) const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } const props: TeamActionProps = { sessionId, useSession: bindSnapshotSelector(session), - useSessions: bindSnapshotSelector(sessions), + useProjection: ((key: string, select?: (value: unknown) => unknown) => { + const value = useSessions(state => state.projectionsBySession[sessionId]?.values[ + key as keyof SessionListState['projectionsBySession'][SessionId]['values'] + ]) + return select === undefined ? value : select(value) + }) as UseProjection, + useSessions, useSessionStatus: bindSnapshotSelector(statuses), ...injected, t: makeTranslate(zh, commonZh), @@ -128,6 +135,24 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') }) + it('reads the current Team and model through the standard projection hook', () => { + const b = bench() + const projected = createSnapshotStore({ ...team, tasks: [{ ...task, subject: 'Scoped Team task' }] }) + const useTeam = bindSnapshotSelector(projected) + const useProjection = ((key: string, select?: (value: unknown) => unknown) => { + const current = useTeam(value => value) + const value = key === 'agentTeam' ? current : { next: { model: 'scoped-model' } } + return select === undefined ? value : select(value) + }) as UseProjection + render() + openPanel() + expect(screen.getByText('Scoped Team task')).toBeTruthy() + expect(screen.queryByText('Implement runtime')).toBeNull() + expect(screen.getByRole('button', { name: /lead.*scoped-model/u })).toBeTruthy() + act(() => { projected.set({ ...team, tasks: [{ ...task, subject: 'Updated scoped task' }] }) }) + expect(screen.getByText('Updated scoped task')).toBeTruthy() + }) + it('overlays live Session status and the durable model selection on roster rows', () => { const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]]) const b = bench({ From 843e1b97af5c9eb016623aa41e0896ea129ddacc Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 14:27:00 +0800 Subject: [PATCH 05/25] fix(agent-team): scope projection reads and address review feedback --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 6 +- .../feature/2026-08-05-agent-teams.zh.md | 6 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 +- docs/capability-seams.zh.md | 4 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 20 +-- docs/persistence-catalog.zh.md | 20 +-- docs/persistence-schema.json | 12 +- docs/subsystems/agent-team.i18n.yaml | 4 +- docs/subsystems/agent-team.md | 61 ++++++-- docs/subsystems/agent-team.zh.md | 61 ++++++-- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 7 +- packages/experimental/agent-team/README.zh.md | 7 +- packages/experimental/agent-team/package.json | 20 +-- .../experimental/agent-team/src/client.ts | 1 - packages/experimental/agent-team/src/index.ts | 19 +-- .../experimental/agent-team/src/projection.ts | 2 + packages/experimental/agent-team/src/types.ts | 6 - .../agent-team/tests/built-lib.e2e.ts | 10 +- .../agent-team/tests/team.spec.ts | 18 --- .../experimental/agent-team/tsconfig.json | 1 - .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 6 +- .../client-ui-agent-team/README.zh.md | 6 +- .../src/client/TeamAction.tsx | 147 ++++++++++++------ .../src/client/locales.ts | 2 + .../client-ui-agent-team/src/client/mount.ts | 4 +- .../tests/team-action.client.spec.tsx | 117 +++++++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 10 -- pnpm-lock.yaml | 3 - scripts/gen-cordis-catalog.ts | 3 +- scripts/gen-doc-graphs.ts | 4 +- scripts/type-equiv.manifest.json | 15 ++ 39 files changed, 413 insertions(+), 221 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index f2228c4045..5a98fcfa4d 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 29b53ee5057c44b9ca4d959cf865e4b989b4070e -2026-08-05-agent-teams.zh.md: e41812e25b41c96cf92cc2ee876ebc0d6c566bcd +2026-08-05-agent-teams.md: 073b0ac745e5bb171987ff11b4bfba0a50a8087a +2026-08-05-agent-teams.zh.md: 0fbb477429d10fbfbcbfb5437e977f597f3ce4c2 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 29b53ee505..073b0ac745 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -56,7 +56,11 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Web projection -The Web panel reads the Lead Session's `agentTeam` wire projection instead of polling a Remote read, because the projection registry already broadcasts one Host-wide frame per changed client view to every connected browser, and the subagent catalog panel proves that path needs no refresh control. The wire view carries durable facts only: roster rows with their durable phase, non-deleted task views with the same owner-name, readiness, and write-overlap derivation the task board returns to agents, and the projection `failure` beside the last valid state so a rejected persisted record is visible instead of appearing as a stale but healthy board. Live turn activity and the model stay out of the view because they are process-local; the browser overlays Session status and the member Session's `modelSelection` projection, and shows no model before a durable selection or request exists. Publication requires the projection `apply` to return a new state object per applied Team event and to replace only the touched collection, so mailbox-only events keep the previous view reference and publish nothing. The `agentTeams/view` Remote method remains for callers that need live availability and the configured model. +The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Current-session values use the standard `useProjection` hook; a teammate conversation reads its Lead through the shared cross-Session store. + +The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; opening the panel requests other active members' baselines without opening their conversations, and no model is shown before a durable selection or request exists. + +Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index e41812e25b..0fbb477429 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -56,7 +56,11 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Web projection -Web panel 读取 Lead Session 的 `agentTeam` wire projection 而不是轮询 Remote 读取,因为 projection registry 已经向每个已连接浏览器广播每次客户端视图变化的一帧 Host 级 frame,而 subagent catalog panel 证明这条路径不需要刷新控件。wire 视图只承载持久事实:带持久 phase 的 roster row、与任务板返回给 agent 相同的 owner 名称、就绪状态与写入范围重叠派生的未删除任务视图,以及与最后有效状态并列的 projection `failure`,使被拒绝的持久记录可见而不是表现为陈旧但健康的任务板。实时轮次活动与模型不进入视图,因为它们是进程本地的;浏览器叠加 Session 状态与成员 Session 的 `modelSelection` projection,并在持久选择或请求存在之前不显示模型。发布要求 projection `apply` 对每个已应用的 Team 事件返回新的 state 对象并只替换被触及的集合,因此仅邮箱事件保留上一个视图引用且不发布。`agentTeams/view` Remote method 保留给需要实时可用性与配置模型的调用方。 +Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。当前会话的值使用标准 `useProjection` 钩子;teammate 会话通过共享的跨 Session store 读取其 Lead。 + +传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;打开 panel 时请求其他 active 成员的基线,无需打开它们的会话,在持久选择或请求出现前不显示模型。 + +发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 ## Alternatives considered diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 6e360392ad..581ef4950d 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: aac4813e5c375af311a36c10820909f85641ca4e -capability-seams.zh.md: f0e584f92658efb7cb34601aeb88494aa8085293 +capability-seams.md: b5d763a019ed4a67e975928410a20e7e5464619e +capability-seams.zh.md: 2e183905bbb949e3cca88aa6da3469bcd73c0bcb diff --git a/docs/capability-seams.md b/docs/capability-seams.md index aac4813e5c..b5d763a019 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -230,7 +230,6 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_inspector["inspector"] svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] @@ -416,7 +415,6 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -638,7 +636,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index f0e584f926..2e183905bb 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -232,7 +232,6 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_inspector["inspector"] svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] @@ -418,7 +417,6 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -640,7 +638,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 64f34ae432..0dd1410723 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 5075b3ee2937d9fc4ad9c6bc6e67ecd6f7e17cad -config-catalog.zh.md: 9a464c285e1388c41348ce09925e84b7fe1d7324 +config-catalog.md: dd83fe4253ee42f0179c216583df76234c588049 +config-catalog.zh.md: 8bf242b33fc473db6e9cf0645ad908baf24d86a8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5075b3ee29..dd83fe4253 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -742,7 +742,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:155`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9a464c285e..8bf242b33f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -744,7 +744,7 @@ export interface Config { } ``` -来源: [`packages/experimental/agent-team/src/types.ts:161`](../packages/experimental/agent-team/src/types.ts) +来源: [`packages/experimental/agent-team/src/types.ts:155`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d55c8e1360..daebfcaf5e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 8f07cc9512433704a63f30c5ae1aee9b08ade44e -persistence-catalog.zh.md: 4fd589d3243057104bc36cfd5c5acb66f30cd059 +persistence-catalog.md: 7f7fe1521a5fa23b8fce28fba6d15be586feec6c +persistence-catalog.zh.md: 7e6b90d79d07745bcff2aab50bc02c2147f9cda4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 8f07cc9512..7f7fe1521a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:140`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:245`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4fd589d324..7e6b90d79d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:146`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:140`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:245`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:247`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:243`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index e309173b34..acbf7f3344 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:247" + "packages/experimental/agent-team/src/types.ts:241" ] }, { @@ -31770,7 +31770,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSource" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:146" + "packages/experimental/agent-team/src/types.ts:140" ] }, { @@ -33739,7 +33739,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:137" + "packages/experimental/agent-team/src/types.ts:131" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:243" + "packages/experimental/agent-team/src/types.ts:237" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:241" + "packages/experimental/agent-team/src/types.ts:235" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:245" + "packages/experimental/agent-team/src/types.ts:239" ] }, { diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 6a04e1377d..4551177af7 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md -agent-team.md: 2f49e164645e6b66bd0da4946c477a09c412ddba -agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810 +agent-team.md: dc86afa2d682996eed0d11be42f91736e3675ae5 +agent-team.zh.md: 1ed2164ffbb7cf35d7e8d508e7cde2ca774e7496 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 2f49e16464..dc86afa2d6 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -2,7 +2,7 @@ English | [中文](agent-team.zh.md) -Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). +Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the durable and client-visible forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). ## Identity and roster @@ -73,9 +73,59 @@ interface TeamTaskSnapshot { `pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot. + + +## Web projection + +The Lead Session publishes `SessionProjectionMap.agentTeam` with durable roster rows and non-deleted task views. `failure` reports a rejected persisted record beside the last valid state. Member activity comes from Session status; model labels come from each member's `modelSelection` projection. + +```ts type-equiv +/** One durable roster row published through the `agentTeam` Session projection. */ +interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly error?: string +} +``` + +```ts type-equiv +/** Runtime-enriched task view returned to tools and hosts. */ +interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} +``` + +```ts type-equiv +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} +``` + ## Replay -`foldTeam()` replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. +The `agentTeam` Session projection replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. @@ -176,13 +226,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined - -/** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ -@Remote('view') remoteView(agent: Agent): TeamView ``` Types: [Agent](core.md) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 24d7d73fda..1ed2164ffb 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -2,7 +2,7 @@ [English](agent-team.md) | 中文 -实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。 +实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的持久与客户端可见形式。 ## 身份与 roster @@ -73,9 +73,59 @@ interface TeamTaskSnapshot { `pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。 + + +## Web 投影 + +Lead Session 通过 `SessionProjectionMap.agentTeam` 发布持久 roster 行与未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久记录。成员活动来自 Session 状态;模型标签来自各成员的 `modelSelection` 投影。 + +```ts type-equiv +/** One durable roster row published through the `agentTeam` Session projection. */ +interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly description?: string + readonly provider?: string + readonly context?: 'fresh' | 'fork' + readonly error?: string +} +``` + +```ts type-equiv +/** Runtime-enriched task view returned to tools and hosts. */ +interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} +``` + +```ts type-equiv +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} +``` + ## 回放 -`foldTeam()` 把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 +`agentTeam` Session 投影把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 @@ -176,13 +226,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined - -/** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ -@Remote('view') remoteView(agent: Agent): TeamView ``` Types: [Agent](core.zh.md) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 00326bcff4..4b7b8f5ec1 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: cc2a7a546715d88c5fc7bacf103fbde2e51befc5 -README.zh.md: 321200cf7395277933b00b62f370e2b14629720d +README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc +README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index cc2a7a5467..9c0a351cf6 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -172,9 +172,11 @@ Read these pages when the package-level contract is not enough. They move from t -### Browser projection and Remote +### Browser projection -The `agentTeam` Session projection publishes a durable client view of the Lead Session: the roster with each member's durable phase, the non-deleted task board with owner names, readiness, and write-scope overlap warnings, and `failure` when a persisted Team record was rejected, after which the roster and tasks stay at the last valid state. Every applied Team event replaces the projection state object and only the collection it touched, so the projection registry publishes one Host-wide frame per roster or task change and none for mailbox-only changes. Live turn activity and the selected model are not part of the view; the Web UI overlays them from Session status and the member Session's `modelSelection` projection. `TeamService` also exposes the read-only `agentTeams/view` Remote method, which adds live availability and the configured model; task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the generated Client contribution, and `./client` exports the browser-safe roster, task, and projection types. +The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. + +The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience @@ -199,6 +201,7 @@ Peer messages append after the target's reusable history prefix. Cold resume reu These limits describe what a team cannot do yet or what needs special operational care. They are current package constraints, not a comparison with other coordination mechanisms. +- **Whole-view broadcasts** — each roster or task change sends the complete roster and non-deleted task board, including descriptions, to every connected browser, even when it is viewing another Session. - **Experimental prototype with no stability promise** — the package is public, but its contracts can change freely while it incubates. - **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock. - **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 321200cf73..44a3ade101 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -172,9 +172,11 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch -### 浏览器投影与 Remote +### 浏览器投影 -`agentTeam` Session 投影发布 Lead Session 的持久客户端视图:带每个成员持久 phase 的 roster、带 owner 名称、就绪状态与写入范围重叠警告的未删除任务板,以及在某条持久 Team 记录被拒绝时的 `failure`,此后 roster 与任务停留在最后有效状态。每个已应用的 Team 事件都会替换投影 state 对象,且只替换它触及的集合,因此投影注册表对每次 roster 或任务变化发布一帧 Host 级 frame,对仅邮箱的变化不发布。实时轮次活动与所选模型不在视图内;Web UI 从 Session 状态和成员 Session 的 `modelSelection` 投影叠加它们。`TeamService` 同时公开只读的 `agentTeams/view` Remote method,它额外提供实时可用性与配置的模型;任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出生成的 Client contribution,`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +`agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 + +Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 @@ -199,6 +201,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用 这些限制说明一支团队目前不能做什么、或哪些方面需要特别的运维关注。它们是当前包约束,不是与其他协作机制的对比。 +- **完整视图广播** — 每次 roster 或任务变化都会把完整 roster 和未删除任务板(含描述)发给所有已连接浏览器,即使它正在查看其他 Session。 - **实验原型,无稳定性承诺**——本包公开发布,但孵化期间约定仍可自由变更。 - **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。 - **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。 diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index db127f9f7e..453b011def 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -30,14 +30,6 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, - "./typert": { - "types": "./lib/typert.host.d.ts", - "default": "./lib/typert.host.js" - }, - "./remote": { - "types": "./lib/typert.remote-client.d.ts", - "default": "./lib/typert.remote-client.js" - }, "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" @@ -47,11 +39,7 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/typert.host.js", - "lib/typert.host.d.ts", - "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/types/**/*.d.ts" ], "license": "MIT", "dependencies": { @@ -67,8 +55,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-subagent": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -84,7 +71,6 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^" } } diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index fc5e7e66e5..c56076fa7c 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -8,5 +8,4 @@ export type { TeamTaskId, TeamTaskStatus, TeamTaskView, - TeamView, } from './types.ts' diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 03ddbc7a48..96a79bea16 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -1,10 +1,9 @@ /** Agent Teams service façade over roster, mailbox, task, and runtime lifecycle owners. */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { TeamActivity } from './activity.ts' import { errorMessage, TeamError } from './error.ts' import { TeamJournal } from './journal.ts' @@ -24,7 +23,6 @@ import type { SpawnTeammateResult, TeamMemberView, TeamTaskView, - TeamView, TeamWaitResult, UpdateTeamTaskRequest, } from './types.ts' @@ -55,7 +53,7 @@ function positiveLimit(name: string, value: number): number { } /** Agent Teams service backed by the exact live Lead Session log. */ -export class TeamService extends TypertRemoteService { +export class TeamService extends Service { static inject = ['agents', 'sessions', 'sessionPersistence', 'sessionProjections', 'subagents'] static Config: z = z.object({ @@ -233,19 +231,6 @@ export class TeamService extends TypertRemoteService { return this.roster.tryMembership(agent) } - /** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ - @Remote('view') - remoteView(agent: Agent): TeamView { - return { - members: this.listMembers(agent), - tasks: this.listTasks(agent), - } - } - /** Queue one contained recovery pass after publication has unwound. */ private scheduleRecovery(agent: Agent): void { queueMicrotask(() => { diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 13c83d4175..956b3d257f 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -369,6 +369,8 @@ function buildTeamProjection(state: TeamProjectionState): TeamProjection { /** * Durable client view of one Team state. Mailbox-only state changes reuse the * previous view reference, so the live drive publishes nothing for them. + * A failure is terminal: later events retain the failed state reference and + * do not republish its view. * @param state - current Team state. * @returns the roster and non-deleted task board, plus any projection failure. */ diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 10e82ff32d..0b51530535 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -96,12 +96,6 @@ export interface TeamTaskView { readonly writeScopeWarnings: string[] } -/** Point-in-time roster and task-board projection returned to browser clients. */ -export interface TeamView { - readonly members: TeamMemberView[] - readonly tasks: TeamTaskView[] -} - /** One durable roster row published through the `agentTeam` Session projection. */ export interface TeamMemberProjection { readonly id: SessionId diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 4e2d762fd6..897ba36e4a 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -13,21 +13,17 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href const requiredArtifacts = [ 'packages/experimental/agent-team/lib/index.js', - 'packages/experimental/agent-team/lib/typert.remote-client.js', ].every(path => existsSync(artifact(path))) describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { - it('loads the Host service and its generated browser contribution under plain Node', async () => { + it('loads the Host service under plain Node', async () => { const urls = { host: artifactUrl('packages/experimental/agent-team/lib/index.js'), - remote: artifactUrl('packages/experimental/agent-team/lib/typert.remote-client.js'), } const script = ` const host = await import(${JSON.stringify(urls.host)}) - const remote = await import(${JSON.stringify(urls.remote)}) console.log(JSON.stringify({ className: host.default.name, - methods: remote.default.descriptors.map(descriptor => descriptor.id), })) ` @@ -35,13 +31,9 @@ describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { className: string - methods: string[] } expect(output).toEqual({ className: 'TeamService', - methods: [ - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/view', - ], }) }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 15c227b303..55b6b5efe3 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -873,24 +873,6 @@ describe('Team shared task DAG', () => { }) }) -describe('Team Remote API', () => { - it('reads tasks created and updated by Team agents', async () => { - const { ctx, lead } = await setup([]) - expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) - expect(ctx.agentTeams.remoteView(lead)).toEqual({ - members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })], - tasks: [], - }) - const created = await ctx.agentTeams.createTask(lead, { - subject: 'Agent task', description: 'Created by the Team Lead', - }) - const updated = await ctx.agentTeams.updateTask(lead, { - taskId: created.id, expectedRevision: 1, action: 'claim', - }) - expect(ctx.agentTeams.remoteView(lead).tasks).toEqual([updated]) - }) -}) - describe('Team mailbox and waiting', () => { it('steers a message addressed to the Lead and checkpoints its receipt', async () => { const { ctx, lead } = await setup(['hang']) diff --git a/packages/experimental/agent-team/tsconfig.json b/packages/experimental/agent-team/tsconfig.json index 862b4b5836..669a476e44 100644 --- a/packages/experimental/agent-team/tsconfig.json +++ b/packages/experimental/agent-team/tsconfig.json @@ -13,7 +13,6 @@ { "path": "../../llm/llm" }, { "path": "../../core/session" }, { "path": "../../core/agent" }, - { "path": "../../typert/protocol" }, { "path": "../../subagent/subagent" }, { "path": "../../session/session-persistence" }, { "path": "../../session/session-projection" }, diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index c61dc57ea4..455eea79d0 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 23617b1f3f7da511f1a1f5fa588450d0c484c94f -README.zh.md: f119df4fbd62c2a1c7ff365fdf53faa90fe3e423 +README.md: 5244cb6e9e02a106fb52e6ac882b9b550a180af5 +README.zh.md: 35031d9410f5c38edb3e0d9f3a914c2368487724 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 23617b1f3f..5244cb6e9e 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's explicit projection baseline once per connection, skipping a successful prior read; a failed read shows the failure with a retry control, and a reconnect reloads it. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. The current Session supplies its Team baseline through normal conversation loading. In a teammate conversation, opening the panel requests the Lead's projection baseline once per connection, skipping a successful prior read; a failed parent read shows a retry control alongside any last valid Team, and a reconnect reloads it. Current Session loading failures follow the conversation lifecycle and appear in the panel without a separate projection retry. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` supplies that other Session's Team projection. Other members' models also come from the shared Session values; member activity comes from `useSessionStatus` with the list summary as fallback. Its only injected callbacks read the Lead's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity, and separate `useSessions` selectors read the parent's Team value, baseline-read state, and error. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| @@ -84,7 +84,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. -- **Model before the first request** — a member without a durable model selection or request shows no model, and a teammate Session that is not loaded on the Host shows no model or activity until its projections arrive. +- **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index f119df4fbd..35031d9410 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时,每次连接请求一次 Lead 的显式投影基线,之前读取成功则跳过;读取失败会显示失败原因与重试控件,重连后会重新加载。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。当前 Session 通过普通会话加载提供 Team 基线。在 teammate 会话中,打开 panel 时每次连接请求一次 Lead 的投影基线,之前读取成功则跳过;父会话读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。当前 Session 的加载失败沿用会话生命周期,并显示在 panel 中,不提供单独的投影重试。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 提供该另一个 Session 的 Team 投影。其他成员的 model 也来自共享 Session 值;成员活动来自 `useSessionStatus`,并以列表摘要为后备。它仅有的注入回调是读取 Lead 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,独立的 `useSessions` selector 分别读取父会话的 Team 值、基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| @@ -84,7 +84,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 -- **首次请求前没有 model**——没有持久模型选择或请求的成员不显示 model;未在 Host 加载的 teammate Session 在其投影到达前不显示 model 与活动。 +- **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index c6661bed06..2eb15582c1 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -17,8 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Request the Lead's projection baseline once per connection, or retry a failed read. */ - loadProjections: (leadSessionId: SessionId) => void + /** Request another Session's projection baseline once per connection, or retry a failed read. */ + loadProjections: (sessionId: SessionId) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -71,6 +71,52 @@ function taskDotState(task: TeamTask): StateDotState { } } +type TeamMemberRowProps = Pick & { + member: TeamMemberProjection + onError: (message: string) => void +} + +function TeamMemberRow({ + member, sessionId, useProjection, useSessions, useSessionStatus, openTeammate, onError, t, +}: TeamMemberRowProps) { + const currentModel = useProjection('modelSelection', value => + member.id === sessionId ? value?.next?.model : undefined) + const otherModel = useSessions(state => member.id === sessionId + ? undefined + : state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) + const running = useSessionStatus(state => state.get(member.id)?.running) + const summaryRunning = useSessions(state => state.byId[member.id]?.running) + const status: MemberStatus = member.phase === 'active' + ? (running ?? summaryRunning) === true ? 'running' : 'inactive' + : member.phase + const model = member.id === sessionId ? currentModel : otherModel + + return ( + + ) +} + /** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ export function TeamAction({ sessionId, useSession, useProjection, useSessions, useSessionStatus, loadProjections, openTeammate, t, @@ -87,14 +133,25 @@ export function TeamAction({ useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const isLead = leadSessionId === sessionId const currentTeam = useProjection('agentTeam') - const currentModel = useProjection('modelSelection', value => value?.next?.model) - const projections = useSessions(state => state.projectionsBySession) - const summaries = useSessions(state => state.byId) - const statuses = useSessionStatus(snapshot => snapshot) - const projection = projections[leadSessionId] - const team = leadSessionId === sessionId ? currentTeam : projection?.values.agentTeam - const readError = team === undefined && projection?.state === 'error' ? projection.error : null + const openState = useSession(snapshot => snapshot.openState) + const openError = useSession(snapshot => snapshot.openError) + const parentTeam = useSessions(state => isLead + ? undefined + : state.projectionsBySession[leadSessionId]?.values.agentTeam) + const parentState = useSessions(state => isLead + ? undefined + : state.projectionsBySession[leadSessionId]?.state) + const parentError = useSessions(state => isLead + ? null + : state.projectionsBySession[leadSessionId]?.error ?? null) + const team = isLead ? currentTeam : parentTeam + const readError = isLead + ? openState === 'error' ? openError : null + : parentState === 'error' ? parentError : null + const ready = isLead ? openState === 'open' : parentState === 'ready' + const members = team?.members useEffect(() => { setOpen(false) @@ -102,8 +159,15 @@ export function TeamAction({ }, [sessionId]) useEffect(() => { - if (open) loadProjections(leadSessionId) - }, [open, leadSessionId, loadProjections]) + if (open && !isLead) loadProjections(leadSessionId) + }, [open, isLead, leadSessionId, loadProjections]) + + useEffect(() => { + if (!open) return + for (const member of members ?? []) { + if (member.id !== sessionId && member.id !== leadSessionId && member.phase === 'active') loadProjections(member.id) + } + }, [open, sessionId, leadSessionId, members, loadProjections]) useLayoutEffect(() => { if (open && positioned) panelRef.current?.focus() @@ -114,14 +178,6 @@ export function TeamAction({ triggerRef.current?.focus() } - const memberStatus = (member: TeamMemberProjection): MemberStatus => { - if (member.phase === 'failed') return 'failed' - if (member.phase === 'provisioning') return 'provisioning' - return (statuses.get(member.id)?.running ?? summaries[member.id]?.running) === true ? 'running' : 'inactive' - } - const memberModel = (member: TeamMemberProjection): string | undefined => - member.id === sessionId ? currentModel : projections[member.id]?.values.modelSelection?.next?.model - const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] return ( @@ -171,13 +227,18 @@ export function TeamAction({
{failureText(readError)} - + {!isLead && ( + + )}
)} {team === undefined && readError === null && ( -
{t('loading')}
+
+ + {t(ready ? 'unavailable' : 'loading')} +
)} {team !== undefined && ( <> @@ -187,33 +248,19 @@ export function TeamAction({

{t('roster')}

- {team.members.map((member) => { - const status = memberStatus(member) - const model = memberModel(member) - return ( - - ) - })} + {team.members.map(member => ( + + ))}
diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 8d3c45d513..c3365d4268 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -9,6 +9,7 @@ export const zh = { close: '关闭', retry: '重试读取 Team', loading: '正在加载 Team…', + unavailable: 'Team 暂不可用', failure: 'Team 持久记录无效:{message}', empty: '还没有共享任务', roster: '成员', @@ -39,6 +40,7 @@ export const en = { close: 'Close', retry: 'Retry reading Team', loading: 'Loading Team…', + unavailable: 'Team is unavailable', failure: 'Invalid persisted Team record: {message}', empty: 'No shared tasks yet', roster: 'Members', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index d7e9083307..1e2dfbdab3 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -36,8 +36,8 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(leadId: SessionId): void { - void sessions.refreshProjections(leadId) + loadProjections(sessionId: SessionId): void { + void sessions.refreshProjections(sessionId) }, openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index f150de9021..9949212f5e 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { Profiler } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -48,6 +49,7 @@ function bench(options: { projections?: Projections sessionId?: SessionId parentSessionId?: SessionId + openState?: SessionSnapshot['openState'] statuses?: SessionStatusSnapshot running?: Record } = {}) { @@ -67,7 +69,7 @@ function bench(options: { ? null : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, removed: false, - openState: 'open', + openState: options.openState ?? 'open', openError: null, hasMore: false, loadingOlder: false, @@ -123,7 +125,7 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -195,8 +197,8 @@ describe('TeamAction', () => { expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) }) - it('shows loading until the projection arrives and a retryable read failure', () => { - const b = bench({ projections: {} }) + it('loads the parent Team and retries failed parent baseline reads', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION, projections: {} }) render() openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) @@ -215,6 +217,109 @@ describe('TeamAction', () => { expect(screen.queryByRole('alert')).toBeNull() }) + it('uses the current Session opening state without requesting its projection baseline', () => { + const b = bench({ projections: {}, openState: 'loading' }) + render() + openPanel() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + expect(b.injected.loadProjections).not.toHaveBeenCalled() + + act(() => { + b.session.update((draft) => { + draft.openState = 'error' + draft.openError = new RemoteError('gateway/internal', 'history unavailable', {}) + }) + }) + expect(screen.getByRole('alert').textContent).toBe('history unavailable (gateway/internal)') + expect(screen.queryByRole('button', { name: zh.retry })).toBeNull() + expect(b.injected.loadProjections).not.toHaveBeenCalled() + + act(() => { + b.session.update((draft) => { + draft.openState = 'open' + draft.openError = null + }) + }) + expect(screen.getByRole('status').textContent).toBe(zh.unavailable) + setProjection(b.sessions, SESSION, team) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) + }) + + it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { + const b = bench(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}) + const onRender = vi.fn() + render() + openPanel() + onRender.mockClear() + + act(() => { + const current = b.sessions.getSnapshot() + const projectionsBySession = Object.fromEntries(Object.entries(current.projectionsBySession) + .map(([id, snapshot]) => [id, { ...snapshot }])) + b.sessions.set({ + ...current, + byId: { ...current.byId, unrelated: summary('unrelated' as SessionId, true) }, + projectionsBySession: { + ...projectionsBySession, + unrelated: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, + }, + }) + b.statuses.set(new Map([['unrelated' as SessionId, { running: true, pendingInteraction: undefined, completionUnread: false }]])) + }) + expect(onRender).not.toHaveBeenCalled() + + setProjectionSnapshot(b.sessions, WORKER, { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'updated-worker-model' } } }, + }) + expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) + expect(screen.getByText('Updated parent task')).toBeTruthy() + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(addressed ? WORKER : SESSION) + }) + + it('shows capability absence after a successful read instead of loading forever', () => { + const b = bench({ projections: { [SESSION]: { state: 'ready', error: null, values: {} } } }) + render() + openPanel() + expect(screen.queryByText(zh.loading)).toBeNull() + expect(screen.getByRole('status').textContent).toBe('Team 暂不可用') + }) + + it('keeps the last parent Team visible beside a retryable baseline failure', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION }) + render() + openPanel() + setProjectionSnapshot(b.sessions, SESSION, { + state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: { agentTeam: team }, + }) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') + fireEvent.click(screen.getByRole('button', { name: zh.retry })) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) + }) + + it('loads active members models without opening their conversations', () => { + const nextMember = { ...worker, id: 'new-worker' as SessionId, name: 'new-worker', phase: 'provisioning' as const } + const failed = { ...worker, id: 'failed-worker' as SessionId, name: 'failed-worker', phase: 'failed' as const } + const b = bench() + render() + expect(b.injected.loadProjections).not.toHaveBeenCalled() + openPanel() + expect(b.injected.loadProjections).toHaveBeenCalledWith(WORKER) + setProjectionSnapshot(b.sessions, WORKER, { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'cold-worker-model' } } }, + }) + expect(screen.getByRole('button', { name: /worker.*cold-worker-model/u })).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, members: [...team.members, nextMember, failed] }) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(nextMember.id) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(failed.id) + setProjection(b.sessions, SESSION, { ...team, members: [...team.members, { ...nextMember, phase: 'active' }] }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(nextMember.id) + }) + it('surfaces a Team projection failure beside the last valid state', () => { const b = bench({ projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } }, @@ -284,13 +389,13 @@ describe('TeamAction', () => { fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(screen.getByRole('alert')).toBeTruthy() - const next = bench({ sessionId: 'next-lead' as SessionId, projections: {} }) + const next = bench({ sessionId: 'next-lead' as SessionId, projections: {}, openState: 'loading' }) rendered.rerender() expect(screen.queryByRole('dialog')).toBeNull() openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') + expect(next.injected.loadProjections).not.toHaveBeenCalled() }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 930a7c6b56..cf77809950 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -376,12 +376,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'candidate exact live Agent.' }], returns: 'Team membership, or undefined for non-Team subagents and stale identities.', }, - { - signature: '@Remote(\'view\') remoteView(agent: Agent): TeamView', - description: 'Read the current roster and non-deleted task board through the generated Remote API.', - parameters: [{ name: 'agent', description: 'exact live Team member used as the authority credential.' }], - returns: 'detached current roster and task views.', - }, ], }, { @@ -7031,10 +7025,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TeamTaskView', declaration: 'export interface TeamTaskView {\n readonly id: TeamTaskId;\n readonly revision: number;\n readonly subject: string;\n readonly description: string;\n readonly status: TeamTaskStatus;\n readonly blockedBy: TeamTaskId[];\n readonly writeScopes: string[];\n readonly ownerName?: string;\n readonly ready: boolean;\n readonly writeScopeWarnings: string[];\n}', }, - { - name: 'TeamView', - declaration: 'export interface TeamView {\n readonly members: TeamMemberView[];\n readonly tasks: TeamTaskView[];\n}', - }, { name: 'TeamWaitResult', declaration: 'export interface TeamWaitResult {\n readonly timedOut: boolean;\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb69cfc475..da098d5cc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6972,9 +6972,6 @@ importers: '@deepseek-ai/dsh-subagent-spawn-in-process': specifier: workspace:^ version: link:../../subagent/subagent-spawn-in-process - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol packages/experimental/agent-team-profile: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1caa45f192..d5530c9d26 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -649,7 +649,8 @@ export const LINK_MAP: Readonly> = { TeamMembership: 'agent-team.md', TeamTaskId: 'agent-team.md', TeamTaskView: 'agent-team.md', - TeamView: 'agent-team.md', + TeamProjection: 'agent-team.md', + TeamMemberProjection: 'agent-team.md', TeamWaitResult: 'agent-team.md', UpdateTeamTaskRequest: 'agent-team.md', TokenMeasurement: 'token-meter.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ebc7dcd277..5cf56de8d0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -708,8 +708,8 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'experimental-agent-team', title: 'Agent Teams coordination domain', mode: 'core', - consumers: ['experimental-tool-agent-team', 'experimental-client-ui-agent-team'], - note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution.', + consumers: ['experimental-tool-agent-team'], + note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls.', }, { key: 'inspector', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 13b995afc4..48d3b928d7 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -337,6 +337,21 @@ "symbol": "ScheduleView", "source": "packages/schedule/schedule/src/types.ts" }, + { + "doc": "docs/subsystems/agent-team.md", + "symbol": "TeamMemberProjection", + "source": "packages/experimental/agent-team/src/types.ts" + }, + { + "doc": "docs/subsystems/agent-team.md", + "symbol": "TeamTaskView", + "source": "packages/experimental/agent-team/src/types.ts" + }, + { + "doc": "docs/subsystems/agent-team.md", + "symbol": "TeamProjection", + "source": "packages/experimental/agent-team/src/types.ts" + }, { "doc": "docs/subsystems/agent-team.md", "symbol": "TeamMemberSnapshot", From 7befda1147b9511e6daa8effc72d51257d8a0cfe Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 14:32:01 +0800 Subject: [PATCH 06/25] test(agent-team): release fixtures and respect snapshot types --- .../agent-team/tests/built-lib.e2e.ts | 2 +- .../experimental/agent-team/tests/team.spec.ts | 7 ++++++- .../tests/team-action.client.spec.tsx | 16 +++++++--------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 897ba36e4a..d171624d80 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -1,4 +1,4 @@ -/** Plain-Node smoke for the built Agent Teams service and Remote contribution. */ +/** Plain-Node smoke for the built Agent Teams service. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 55b6b5efe3..af939b0108 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -22,9 +22,11 @@ import { TestSessionQuery } from './test-session-query.ts' const SIGNAL = new AbortController().signal const roots: string[] = [] +const contexts: Context[] = [] -afterEach(() => { +afterEach(async () => { vi.useRealTimers() + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -60,6 +62,7 @@ async function setup( config: ConstructorParameters[1] = {}, ) { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) roots.push(storageRoot) @@ -167,6 +170,7 @@ describe('Team identity and provisioning', () => { it('supports direct-constructor defaults and recovers roots that already exist', async () => { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-')) roots.push(storageRoot) @@ -1346,6 +1350,7 @@ describe('Team mailbox and waiting', () => { it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-')) roots.push(storageRoot) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 9949212f5e..c1c588995a 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -225,9 +225,10 @@ describe('TeamAction', () => { expect(b.injected.loadProjections).not.toHaveBeenCalled() act(() => { - b.session.update((draft) => { - draft.openState = 'error' - draft.openError = new RemoteError('gateway/internal', 'history unavailable', {}) + b.session.set({ + ...b.session.getSnapshot(), + openState: 'error', + openError: new RemoteError('gateway/internal', 'history unavailable', {}), }) }) expect(screen.getByRole('alert').textContent).toBe('history unavailable (gateway/internal)') @@ -235,10 +236,7 @@ describe('TeamAction', () => { expect(b.injected.loadProjections).not.toHaveBeenCalled() act(() => { - b.session.update((draft) => { - draft.openState = 'open' - draft.openError = null - }) + b.session.set({ ...b.session.getSnapshot(), openState: 'open', openError: null }) }) expect(screen.getByRole('status').textContent).toBe(zh.unavailable) setProjection(b.sessions, SESSION, team) @@ -259,10 +257,10 @@ describe('TeamAction', () => { .map(([id, snapshot]) => [id, { ...snapshot }])) b.sessions.set({ ...current, - byId: { ...current.byId, unrelated: summary('unrelated' as SessionId, true) }, + byId: { ...current.byId, ['unrelated' as SessionId]: summary('unrelated' as SessionId, true) }, projectionsBySession: { ...projectionsBySession, - unrelated: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, + ['unrelated' as SessionId]: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, }, }) b.statuses.set(new Map([['unrelated' as SessionId, { running: true, pendingInteraction: undefined, completionUnread: false }]])) From db4bfb4c9665b3f0e5e4ac10eb23a6d1eac53b04 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 14:37:05 +0800 Subject: [PATCH 07/25] docs(agent-team): refresh generated module dependencies --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 +-- docs/module-graph.zh.md | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 68b1e636b1..a1c8765cd8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: ce9a4256c71e79193a359549cefd89cd37e5fa22 -module-graph.zh.md: 557415a750798c039547b9824c6fb8fa882f2c44 +module-graph.md: ef70bdf4a002d764a1fca6ffd5e67163e93027aa +module-graph.zh.md: 729b76c88e6d5f3dc1c2abcea0c5852d1b39348f diff --git a/docs/module-graph.md b/docs/module-graph.md index ce9a4256c7..ef70bdf4a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1249,7 +1249,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_agent_team --> pkg_typert_protocol pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1638,7 +1637,7 @@ flowchart TD | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 557415a750..729b76c88e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1251,7 +1251,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_agent_team --> pkg_typert_protocol pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1640,7 +1639,7 @@ flowchart TD | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From 749f05e9fa57680810603271b2c142f952ef6912 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 15:38:32 +0800 Subject: [PATCH 08/25] fix(agent-team): refresh projections when opening the panel --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/sessions.ts | 13 ++++- .../session-controller/src/client/index.ts | 2 +- .../src/client/sessions/manager.ts | 18 ++++-- .../src/client/sessions/service.ts | 9 +-- .../tests/manager.client.spec.ts | 57 +++++++++++++++++++ .../tests/sessions-service.client.spec.ts | 11 ++++ .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 6 +- .../client-ui-agent-team/README.zh.md | 6 +- .../src/client/TeamAction.tsx | 34 ++++------- .../client-ui-agent-team/src/client/mount.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 11 ++-- .../tests/team-action.client.spec.tsx | 52 +++++++++-------- .../src/client/api-catalog.ts | 12 ++-- 20 files changed, 169 insertions(+), 86 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index a6a1d20012..31847df767 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a -README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec +README.md: e00acab6862e66f1c1f34ce81f0249513c9bf5c8 +README.zh.md: b2ad89a8664d54f70d2364aeaf3b1c7d3b3f5bff diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index e12ad7623e..e00acab686 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,7 +39,7 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. -Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. +Projection reads retain loading and failure state independently of their values. `refreshProjections` reuses a successful baseline within one connection by default. Passing `{ force: true }` requests a fresh baseline after any running read; concurrent forced calls during that read share one follow-up. Session removal, reconnect, and disposal cancel queued follow-ups. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 5f4ceec9a8..b2ad89a866 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,7 +39,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 -投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 +投影读取独立于其值保留加载与失败状态。`refreshProjections` 默认复用同一连接内已成功读取的基线。传入 `{ force: true }` 会在当前读取结束后请求新基线;当前读取期间并发的强制刷新调用共用一次后续读取。会话移除、重连和释放会取消排队的后续读取。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 8f016ab711..cbe0ba8e0d 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -21,6 +21,12 @@ export type { AgentContext } from '../scope.ts' /** Known Session identity or durable direct-parent subagent address; an address owns no lifetime. */ export type SessionTarget = SessionId | SubagentAddress +/** Cache policy for an explicit Session projection read. */ +export interface SessionProjectionRefreshOptions { + /** Read again after any current request; concurrent forced reads share that follow-up. */ + readonly force?: boolean +} + /** One independent use of an exact Client generation, without Host Agent ownership. */ export interface SessionReference extends Disposable { readonly sessionId: SessionId @@ -96,11 +102,12 @@ export interface ISessions { subagentAddress(id: SessionId): SubagentAddress | undefined /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Load all Session projections once per connection; retry unsuccessful reads. * @param sessionId - Session to inspect without opening its conversation. - * @returns completion of the current or newly started refresh. + * @param options - force a fresh read even after a successful baseline; a running read finishes first. + * @returns completion of the requested read; removal, reconnect, or disposal cancels a queued refresh. */ - refreshProjections(sessionId: SessionId): Promise + refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise /** * Refresh the Host-authoritative Session list. diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index b9600f36a6..dcd44580ca 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -50,7 +50,7 @@ export type { SubmissionHandle, } from './contract/session.ts' export type { - ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 50af09df13..f1650011a8 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,7 +25,7 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' -import type { SessionTarget } from '../contract/sessions.ts' +import type { SessionProjectionRefreshOptions, SessionTarget } from '../contract/sessions.ts' function sessionSeqCursor(value: number): SessionSeqCursor { return value === -1 ? -1 : SessionSeq(value) @@ -68,6 +68,7 @@ export interface SessionProjectionSnapshot { interface ProjectionInflight { readonly promise: Promise readonly controller: AbortController + refreshAfter?: Promise } type ProjectionLoad = Omit @@ -334,12 +335,19 @@ export class SessionManager { /** * Load a complete projection baseline once per connection; retry unsuccessful reads. * @param sessionId - Session to inspect without opening its conversation. - * @returns completion of the current or newly started read. + * @param options - force a fresh read after any current request, including a cached successful baseline. + * @returns completion of the requested read; aborting the current request also cancels its queued refresh. */ - refreshProjections(sessionId: SessionId): Promise { + refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { const existing = this.projectionInflight.get(sessionId) - if (existing !== undefined) return existing.promise - if (this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() + if (existing !== undefined) { + if (!options?.force) return existing.refreshAfter ?? existing.promise + // A running read may predate plugin activation and omit its new projection. + return existing.refreshAfter ??= existing.promise.then(() => { + if (!existing.controller.signal.aborted) return this.refreshProjections(sessionId, { force: true }) + }) + } + if (!options?.force && this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() const controller = new AbortController() const store = this.projectionStore(sessionId) const initialValues = store.values() diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 48d8f6a533..040835cc53 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -13,7 +13,7 @@ import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protoc import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { - AgentContext, ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + AgentContext, ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from '../contract/sessions.ts' import type { SessionReferenceSource } from '../index.ts' import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from '../scope.ts' @@ -339,11 +339,12 @@ export class ClientSessions implements ISessions { } /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Load all Session projections using the requested cache policy. * @param sessionId - Session to inspect without opening its conversation. + * @param options - optional fresh read after any current request. */ - refreshProjections(sessionId: SessionId): Promise { - return this.manager.refreshProjections(sessionId) + refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { + return this.manager.refreshProjections(sessionId, options) } /** diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 4d91e0b77f..3ac31520b2 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -927,6 +927,63 @@ describe('subagent catalogs', () => { expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) }) + it('refreshes a successful baseline to discover a newly available projection', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) + await manager.refreshProjections(S1) + remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) + await manager.refreshProjections(S1, { force: true }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title).toBe('new capability') + await manager.refreshProjections(S1) + expect(remote.session.projections).toHaveBeenCalledTimes(2) + }) + + it('coalesces forced reads behind an older request without regressing newer frames', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const old = Promise.withResolvers>>() + const fresh = Promise.withResolvers>>() + remote.session.projections.mockImplementationOnce(() => old.promise) + remote.session.projections.mockImplementationOnce(() => fresh.promise) + const initial = manager.refreshProjections(S1) + const refresh = manager.refreshProjections(S1, { force: true }) + expect(manager.refreshProjections(S1, { force: true })).toBe(refresh) + expect(manager.refreshProjections(S1)).toBe(refresh) + expect(remote.session.projections).toHaveBeenCalledOnce() + old.resolve(ok({ asOfSeq: 0, values: {} })) + await initial + expect(remote.session.projections).toHaveBeenCalledTimes(2) + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', seq: 2, value: 'live title' }) + fresh.resolve(ok({ asOfSeq: 1, values: { title: 'older title', subagentCatalog: [] } })) + await refresh + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'ready', values: { title: 'live title', subagentCatalog: [] }, + }) + }) + + it.for(['remove', 'reconnect', 'dispose'] as const)( + 'cancels a queued forced read on %s', + async (action, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const old = Promise.withResolvers>>() + remote.session.projections.mockImplementationOnce(() => old.promise) + remote.session.projections.mockResolvedValue(ok({ asOfSeq: 0, values: { title: 'new connection' } })) + const initial = manager.refreshProjections(S1) + const refresh = manager.refreshProjections(S1, { force: true }) + let disposal: Promise | undefined + if (action === 'remove') manager.handleSessionRemoved(S1) + else if (action === 'reconnect') manager.handleConnected() + else disposal = manager.dispose() + old.resolve(ok({ asOfSeq: 0, values: { title: 'old connection' } })) + await Promise.all([initial, refresh, disposal]) + expect(remote.session.projections).toHaveBeenCalledTimes(action === 'reconnect' ? 2 : 1) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title) + .toBe(action === 'reconnect' ? 'new connection' : undefined) + }, + ) + it('uses current Session status rather than the time an initial catalog read started', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index d4061def11..97af4bbea9 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -719,6 +719,17 @@ describe('borrow-only bindings', () => { }) describe('catalog-addressed navigation', () => { + it('refreshes cached projection baselines through the public Session service', async ({ bench }) => { + const b = bench() + b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) + await b.svc.refreshProjections(sid('root')) + b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) + await b.svc.refreshProjections(sid('root'), { force: true }) + await b.svc.refreshProjections(sid('root')) + expect(b.svc.list.getSnapshot().projectionsBySession[sid('root')]?.values.title).toBe('new capability') + expect(b.mock.remote.session.projections).toHaveBeenCalledTimes(2) + }) + it('retains a projected child independently of its parent and shares its history generation', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'root' }]) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 4b7b8f5ec1..6a0bef282f 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc -README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae +README.md: 79fc15fa369ec4615d3a3531de7c1fd54aa41ee7 +README.zh.md: 8e1905ded14b8cb803298c470085b6ac2a426fd4 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 9c0a351cf6..79fc15fa36 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. -The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. +The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. Opening the panel refreshes the Lead baseline; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 44a3ade101..8e1905ded1 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch `agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 -Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。打开 panel 时会刷新 Lead 基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 455eea79d0..0affc2f3f3 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 5244cb6e9e02a106fb52e6ac882b9b550a180af5 -README.zh.md: 35031d9410f5c38edb3e0d9f3a914c2368487724 +README.md: 8a94b57035d3ed56c2b88b91020eff659384fabe +README.zh.md: cbea68e5aa91ccc27bbcc9f3a89beed2bc2d5580 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 5244cb6e9e..8a94b57035 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. The current Session supplies its Team baseline through normal conversation loading. In a teammate conversation, opening the panel requests the Lead's projection baseline once per connection, skipping a successful prior read; a failed parent read shows a retry control alongside any last valid Team, and a reconnect reloads it. Current Session loading failures follow the conversation lifecycle and appear in the panel without a separate projection retry. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel refreshes the Lead's projection baseline, including when the Team plugin was enabled after the conversation opened. A read already in progress finishes before this refresh starts. Failed reads show a retry control alongside any last valid Team; a reconnect reloads previously requested projections. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity, and separate `useSessions` selectors read the parent's Team value, baseline-read state, and error. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` selects the parent's Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in either conversation. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and projection behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 35031d9410..cbea68e5aa 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。当前 Session 通过普通会话加载提供 Team 基线。在 teammate 会话中,打开 panel 时每次连接请求一次 Lead 的投影基线,之前读取成功则跳过;父会话读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。当前 Session 的加载失败沿用会话生命周期,并显示在 panel 中,不提供单独的投影重试。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时会刷新 Lead 的投影基线,包括在会话打开后才启用 Team 插件的情况。若已有读取正在进行,本次刷新会在其结束后开始。读取失败时,最后有效 Team 旁会显示重试控件;重连会重新加载已请求的投影。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,独立的 `useSessions` selector 分别读取父会话的 Team 值、基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 选择父会话的 Team 值。两种会话都通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| @@ -64,7 +64,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与投影行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 2eb15582c1..cd9ba1ad75 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -5,7 +5,7 @@ import type { TeamMemberProjection, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, @@ -17,8 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Request another Session's projection baseline once per connection, or retry a failed read. */ - loadProjections: (sessionId: SessionId) => void + /** Read a Session's baseline, optionally refreshing after any existing read. */ + loadProjections: (sessionId: SessionId, options?: SessionProjectionRefreshOptions) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -135,22 +135,14 @@ export function TeamAction({ const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId const isLead = leadSessionId === sessionId const currentTeam = useProjection('agentTeam') - const openState = useSession(snapshot => snapshot.openState) - const openError = useSession(snapshot => snapshot.openError) const parentTeam = useSessions(state => isLead ? undefined : state.projectionsBySession[leadSessionId]?.values.agentTeam) - const parentState = useSessions(state => isLead - ? undefined - : state.projectionsBySession[leadSessionId]?.state) - const parentError = useSessions(state => isLead - ? null - : state.projectionsBySession[leadSessionId]?.error ?? null) + const readState = useSessions(state => state.projectionsBySession[leadSessionId]?.state) + const projectionError = useSessions(state => state.projectionsBySession[leadSessionId]?.error ?? null) const team = isLead ? currentTeam : parentTeam - const readError = isLead - ? openState === 'error' ? openError : null - : parentState === 'error' ? parentError : null - const ready = isLead ? openState === 'open' : parentState === 'ready' + const readError = readState === 'error' ? projectionError : null + const ready = readState === 'ready' const members = team?.members useEffect(() => { @@ -159,8 +151,8 @@ export function TeamAction({ }, [sessionId]) useEffect(() => { - if (open && !isLead) loadProjections(leadSessionId) - }, [open, isLead, leadSessionId, loadProjections]) + if (open) loadProjections(leadSessionId, { force: true }) + }, [open, leadSessionId, loadProjections]) useEffect(() => { if (!open) return @@ -227,11 +219,9 @@ export function TeamAction({
{failureText(readError)} - {!isLead && ( - - )} +
)} {team === undefined && readError === null && ( diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 1e2dfbdab3..2f945e4b12 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -36,8 +36,8 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(sessionId: SessionId): void { - void sessions.refreshProjections(sessionId) + loadProjections(sessionId, options): void { + void sessions.refreshProjections(sessionId, options) }, openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index d63394fd64..7bf446d97c 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' +import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -29,8 +30,8 @@ async function bench(options: { addressed?: boolean } = {}) { }, }) } } : undefined, - refreshProjections: (id: SessionId) => { - navigation.push(['refresh', id]) + refreshProjections: (id: SessionId, options?: SessionProjectionRefreshOptions) => { + navigation.push(['refresh', id, options]) return Promise.resolve() }, retainInfo: (id: SessionId) => ({ @@ -88,10 +89,10 @@ describe('ui-team browser plugin', () => { const t = b.ctx.locale.bind('agent-team') expect(t('trigger')).toBe('Agent Team') - b.actions().loadProjections(SESSION) - expect(b.navigation).toEqual([['refresh', SESSION]]) + b.actions().loadProjections(SESSION, { force: true }) + expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) b.actions().openTeammate(SESSION, LEAD) - expect(b.navigation).toEqual([['refresh', SESSION]]) + expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index c1c588995a..0f71861236 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -125,7 +125,7 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -192,7 +192,7 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) }) @@ -217,31 +217,34 @@ describe('TeamAction', () => { expect(screen.queryByRole('alert')).toBeNull() }) - it('uses the current Session opening state without requesting its projection baseline', () => { - const b = bench({ projections: {}, openState: 'loading' }) + it('refreshes an already-open Lead on every panel opening and retries failed reads', () => { + const b = bench({ projections: {}, openState: 'open' }) render() + expect(b.injected.loadProjections).not.toHaveBeenCalled() openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(b.injected.loadProjections).not.toHaveBeenCalled() + expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION, { force: true }) - act(() => { - b.session.set({ - ...b.session.getSnapshot(), - openState: 'error', - openError: new RemoteError('gateway/internal', 'history unavailable', {}), - }) + setProjectionSnapshot(b.sessions, SESSION, { + state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {}, }) - expect(screen.getByRole('alert').textContent).toBe('history unavailable (gateway/internal)') - expect(screen.queryByRole('button', { name: zh.retry })).toBeNull() - expect(b.injected.loadProjections).not.toHaveBeenCalled() + expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') + fireEvent.click(screen.getByRole('button', { name: zh.retry })) + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) - act(() => { - b.session.set({ ...b.session.getSnapshot(), openState: 'open', openError: null }) - }) - expect(screen.getByRole('status').textContent).toBe(zh.unavailable) - setProjection(b.sessions, SESSION, team) - expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(SESSION) + setProjectionSnapshot(b.sessions, SESSION, { state: 'loading', error: null, values: {} }) + expect(screen.getByRole('status').textContent).toBe(zh.loading) + setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) + expect(screen.getByText(zh.empty)).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + + fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + openPanel() + expect(b.injected.loadProjections).toHaveBeenCalledTimes(3) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) }) it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { @@ -274,7 +277,8 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) expect(screen.getByText('Updated parent task')).toBeTruthy() - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(addressed ? WORKER : SESSION) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).not.toHaveBeenCalledWith(WORKER, { force: true }) }) it('shows capability absence after a successful read instead of loading forever', () => { @@ -295,7 +299,7 @@ describe('TeamAction', () => { expect(screen.getByText('Implement runtime')).toBeTruthy() expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) }) it('loads active members models without opening their conversations', () => { @@ -393,7 +397,7 @@ describe('TeamAction', () => { openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).not.toHaveBeenCalled() + expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead', { force: true }) }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 8217db928c..6d51b921c0 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -201,10 +201,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a stable read-only source across same-id generations, with zero counts when none is live.', }, { - signature: 'refreshProjections(sessionId: SessionId): Promise', - description: 'Load all Session projections once per connection; retry an unsuccessful initial read.', - parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }], - returns: 'completion of the current or newly started refresh.', + signature: 'refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise', + description: 'Load all Session projections once per connection; retry unsuccessful reads.', + parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }, { name: 'options', description: 'force a fresh read even after a successful baseline; a running read finishes first.' }], + returns: 'completion of the requested read; removal, reconnect, or disposal cancels a queued refresh.', }, { signature: 'search( query: string, signal: AbortSignal, ): Promise>', @@ -871,6 +871,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionMaybeStandardProps', declaration: 'export interface SessionMaybeStandardProps {\n}', }, + { + name: 'SessionProjectionRefreshOptions', + declaration: 'export interface SessionProjectionRefreshOptions {\n readonly force?: boolean;\n}', + }, { name: 'SessionProviderComponent', declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;', From 700b636508f9692acf492a2d698549671b708dc2 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 16:00:57 +0800 Subject: [PATCH 09/25] fix(agent-team): limit baseline loading fix to the panel --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/contract/sessions.ts | 13 +---- .../session-controller/src/client/index.ts | 2 +- .../src/client/sessions/manager.ts | 18 ++---- .../src/client/sessions/service.ts | 9 ++- .../tests/manager.client.spec.ts | 57 ------------------- .../tests/sessions-service.client.spec.ts | 11 ---- .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 7 ++- .../client-ui-agent-team/README.zh.md | 7 ++- .../src/client/TeamAction.tsx | 10 ++-- .../client-ui-agent-team/src/client/mount.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 11 ++-- .../tests/team-action.client.spec.tsx | 19 +++---- .../src/client/api-catalog.ts | 12 ++-- 20 files changed, 56 insertions(+), 144 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 31847df767..a6a1d20012 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: e00acab6862e66f1c1f34ce81f0249513c9bf5c8 -README.zh.md: b2ad89a8664d54f70d2364aeaf3b1c7d3b3f5bff +README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a +README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index e00acab686..e12ad7623e 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,7 +39,7 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. -Projection reads retain loading and failure state independently of their values. `refreshProjections` reuses a successful baseline within one connection by default. Passing `{ force: true }` requests a fresh baseline after any running read; concurrent forced calls during that read share one follow-up. Session removal, reconnect, and disposal cancel queued follow-ups. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. +Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b2ad89a866..5f4ceec9a8 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,7 +39,7 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 -投影读取独立于其值保留加载与失败状态。`refreshProjections` 默认复用同一连接内已成功读取的基线。传入 `{ force: true }` 会在当前读取结束后请求新基线;当前读取期间并发的强制刷新调用共用一次后续读取。会话移除、重连和释放会取消排队的后续读取。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 +投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index cbe0ba8e0d..8f016ab711 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -21,12 +21,6 @@ export type { AgentContext } from '../scope.ts' /** Known Session identity or durable direct-parent subagent address; an address owns no lifetime. */ export type SessionTarget = SessionId | SubagentAddress -/** Cache policy for an explicit Session projection read. */ -export interface SessionProjectionRefreshOptions { - /** Read again after any current request; concurrent forced reads share that follow-up. */ - readonly force?: boolean -} - /** One independent use of an exact Client generation, without Host Agent ownership. */ export interface SessionReference extends Disposable { readonly sessionId: SessionId @@ -102,12 +96,11 @@ export interface ISessions { subagentAddress(id: SessionId): SubagentAddress | undefined /** - * Load all Session projections once per connection; retry unsuccessful reads. + * Load all Session projections once per connection; retry an unsuccessful initial read. * @param sessionId - Session to inspect without opening its conversation. - * @param options - force a fresh read even after a successful baseline; a running read finishes first. - * @returns completion of the requested read; removal, reconnect, or disposal cancels a queued refresh. + * @returns completion of the current or newly started refresh. */ - refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise + refreshProjections(sessionId: SessionId): Promise /** * Refresh the Host-authoritative Session list. diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index dcd44580ca..b9600f36a6 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -50,7 +50,7 @@ export type { SubmissionHandle, } from './contract/session.ts' export type { - ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index f1650011a8..50af09df13 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -25,7 +25,7 @@ import { Notifier } from './notifier.ts' import { ProjectionValueStore } from './projection-store.ts' import { Session } from './session.ts' import type { SessionRemotes } from './remotes.ts' -import type { SessionProjectionRefreshOptions, SessionTarget } from '../contract/sessions.ts' +import type { SessionTarget } from '../contract/sessions.ts' function sessionSeqCursor(value: number): SessionSeqCursor { return value === -1 ? -1 : SessionSeq(value) @@ -68,7 +68,6 @@ export interface SessionProjectionSnapshot { interface ProjectionInflight { readonly promise: Promise readonly controller: AbortController - refreshAfter?: Promise } type ProjectionLoad = Omit @@ -335,19 +334,12 @@ export class SessionManager { /** * Load a complete projection baseline once per connection; retry unsuccessful reads. * @param sessionId - Session to inspect without opening its conversation. - * @param options - force a fresh read after any current request, including a cached successful baseline. - * @returns completion of the requested read; aborting the current request also cancels its queued refresh. + * @returns completion of the current or newly started read. */ - refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { + refreshProjections(sessionId: SessionId): Promise { const existing = this.projectionInflight.get(sessionId) - if (existing !== undefined) { - if (!options?.force) return existing.refreshAfter ?? existing.promise - // A running read may predate plugin activation and omit its new projection. - return existing.refreshAfter ??= existing.promise.then(() => { - if (!existing.controller.signal.aborted) return this.refreshProjections(sessionId, { force: true }) - }) - } - if (!options?.force && this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() + if (existing !== undefined) return existing.promise + if (this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve() const controller = new AbortController() const store = this.projectionStore(sessionId) const initialValues = store.values() diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 040835cc53..48d8f6a533 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -13,7 +13,7 @@ import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protoc import type { SessionEventSource } from '../contract/events.ts' import type { SessionFace } from '../contract/session.ts' import type { - AgentContext, ISessions, SessionProjectionRefreshOptions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, + AgentContext, ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget, } from '../contract/sessions.ts' import type { SessionReferenceSource } from '../index.ts' import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from '../scope.ts' @@ -339,12 +339,11 @@ export class ClientSessions implements ISessions { } /** - * Load all Session projections using the requested cache policy. + * Load all Session projections once per connection; retry an unsuccessful initial read. * @param sessionId - Session to inspect without opening its conversation. - * @param options - optional fresh read after any current request. */ - refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise { - return this.manager.refreshProjections(sessionId, options) + refreshProjections(sessionId: SessionId): Promise { + return this.manager.refreshProjections(sessionId) } /** diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 3ac31520b2..4d91e0b77f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -927,63 +927,6 @@ describe('subagent catalogs', () => { expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) }) - it('refreshes a successful baseline to discover a newly available projection', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - onTestFinished(() => manager.dispose()) - remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) - await manager.refreshProjections(S1) - remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) - await manager.refreshProjections(S1, { force: true }) - expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title).toBe('new capability') - await manager.refreshProjections(S1) - expect(remote.session.projections).toHaveBeenCalledTimes(2) - }) - - it('coalesces forced reads behind an older request without regressing newer frames', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - onTestFinished(() => manager.dispose()) - const old = Promise.withResolvers>>() - const fresh = Promise.withResolvers>>() - remote.session.projections.mockImplementationOnce(() => old.promise) - remote.session.projections.mockImplementationOnce(() => fresh.promise) - const initial = manager.refreshProjections(S1) - const refresh = manager.refreshProjections(S1, { force: true }) - expect(manager.refreshProjections(S1, { force: true })).toBe(refresh) - expect(manager.refreshProjections(S1)).toBe(refresh) - expect(remote.session.projections).toHaveBeenCalledOnce() - old.resolve(ok({ asOfSeq: 0, values: {} })) - await initial - expect(remote.session.projections).toHaveBeenCalledTimes(2) - manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', seq: 2, value: 'live title' }) - fresh.resolve(ok({ asOfSeq: 1, values: { title: 'older title', subagentCatalog: [] } })) - await refresh - expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ - state: 'ready', values: { title: 'live title', subagentCatalog: [] }, - }) - }) - - it.for(['remove', 'reconnect', 'dispose'] as const)( - 'cancels a queued forced read on %s', - async (action, { mock, remote }) => { - const manager = makeManager(mock, remote) - onTestFinished(() => manager.dispose()) - const old = Promise.withResolvers>>() - remote.session.projections.mockImplementationOnce(() => old.promise) - remote.session.projections.mockResolvedValue(ok({ asOfSeq: 0, values: { title: 'new connection' } })) - const initial = manager.refreshProjections(S1) - const refresh = manager.refreshProjections(S1, { force: true }) - let disposal: Promise | undefined - if (action === 'remove') manager.handleSessionRemoved(S1) - else if (action === 'reconnect') manager.handleConnected() - else disposal = manager.dispose() - old.resolve(ok({ asOfSeq: 0, values: { title: 'old connection' } })) - await Promise.all([initial, refresh, disposal]) - expect(remote.session.projections).toHaveBeenCalledTimes(action === 'reconnect' ? 2 : 1) - expect(manager.getListSnapshot().projectionsBySession[S1]?.values.title) - .toBe(action === 'reconnect' ? 'new connection' : undefined) - }, - ) - it('uses current Session status rather than the time an initial catalog read started', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 97af4bbea9..d4061def11 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -719,17 +719,6 @@ describe('borrow-only bindings', () => { }) describe('catalog-addressed navigation', () => { - it('refreshes cached projection baselines through the public Session service', async ({ bench }) => { - const b = bench() - b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: {} })) - await b.svc.refreshProjections(sid('root')) - b.mock.remote.session.projections.mockResolvedValueOnce(ok({ asOfSeq: 0, values: { title: 'new capability' } })) - await b.svc.refreshProjections(sid('root'), { force: true }) - await b.svc.refreshProjections(sid('root')) - expect(b.svc.list.getSnapshot().projectionsBySession[sid('root')]?.values.title).toBe('new capability') - expect(b.mock.remote.session.projections).toHaveBeenCalledTimes(2) - }) - it('retains a projected child independently of its parent and shares its history generation', async ({ bench }) => { const b = bench() await feedList(b, [{ id: 'root' }]) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 6a0bef282f..4b7b8f5ec1 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 79fc15fa369ec4615d3a3531de7c1fd54aa41ee7 -README.zh.md: 8e1905ded14b8cb803298c470085b6ac2a426fd4 +README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc +README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 79fc15fa36..9c0a351cf6 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. -The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. Opening the panel refreshes the Lead baseline; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. +The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 8e1905ded1..44a3ade101 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch `agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 -Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。打开 panel 时会刷新 Lead 基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 0affc2f3f3..435dcde232 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 8a94b57035d3ed56c2b88b91020eff659384fabe -README.zh.md: cbea68e5aa91ccc27bbcc9f3a89beed2bc2d5580 +README.md: b6930b9cc893cbdf1a9734c8e81c260d23fabbb3 +README.zh.md: 93f9a0d036947bda994298967bc3574e136ce9f4 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 8a94b57035..b6930b9cc8 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,7 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel refreshes the Lead's projection baseline, including when the Team plugin was enabled after the conversation opened. A read already in progress finishes before this refresh starts. Failed reads show a retry control alongside any last valid Team; a reconnect reloads previously requested projections. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline once per connection, both in the Lead conversation and in a teammate conversation. A successful prior read is reused; a failed read shows a retry control alongside any last valid Team, and a reconnect reloads it. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Inspect the task board @@ -47,7 +47,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` selects the parent's Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in either conversation. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` reads its Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in both conversation types. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and projection behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. @@ -83,6 +83,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la +- **Cached capability absence** — if another UI loaded the Lead projection baseline before Agent Teams was enabled, opening this panel reuses that baseline; reconnect to load the newly enabled capability. - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. - **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index cbea68e5aa..93f9a0d036 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。打开 panel 时会刷新 Lead 的投影基线,包括在会话打开后才启用 Team 插件的情况。若已有读取正在进行,本次刷新会在其结束后开始。读取失败时,最后有效 Team 旁会显示重试控件;重连会重新加载已请求的投影。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。无论在 Lead 会话还是 teammate 会话中,打开 panel 时都按每次连接一次的规则请求 Lead 的投影基线。之前读取成功则复用;读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 查看任务板 @@ -47,7 +47,7 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 选择父会话的 Team 值。两种会话都通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 读取其 Team 值。两类会话均通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| @@ -64,7 +64,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与投影行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -83,6 +83,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati +- **能力缺失的缓存** — 如果其他 UI 在启用 Agent Teams 前已读取 Lead 的投影基线,打开本面板会复用该基线;重新连接后才能加载新启用的能力。 - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 - **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index cd9ba1ad75..cd1b765f0c 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -5,7 +5,7 @@ import type { TeamMemberProjection, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, @@ -17,8 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Read a Session's baseline, optionally refreshing after any existing read. */ - loadProjections: (sessionId: SessionId, options?: SessionProjectionRefreshOptions) => void + /** Read a Session's projection baseline once per connection, or retry a failed read. */ + loadProjections: (sessionId: SessionId) => void openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void } @@ -151,7 +151,7 @@ export function TeamAction({ }, [sessionId]) useEffect(() => { - if (open) loadProjections(leadSessionId, { force: true }) + if (open) loadProjections(leadSessionId) }, [open, leadSessionId, loadProjections]) useEffect(() => { @@ -219,7 +219,7 @@ export function TeamAction({
{failureText(readError)} -
diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 2f945e4b12..1e2dfbdab3 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -36,8 +36,8 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(sessionId, options): void { - void sessions.refreshProjections(sessionId, options) + loadProjections(sessionId: SessionId): void { + void sessions.refreshProjections(sessionId) }, openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { if (member.role !== 'teammate') return diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 7bf446d97c..d63394fd64 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,6 +1,5 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { SessionProjectionRefreshOptions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -30,8 +29,8 @@ async function bench(options: { addressed?: boolean } = {}) { }, }) } } : undefined, - refreshProjections: (id: SessionId, options?: SessionProjectionRefreshOptions) => { - navigation.push(['refresh', id, options]) + refreshProjections: (id: SessionId) => { + navigation.push(['refresh', id]) return Promise.resolve() }, retainInfo: (id: SessionId) => ({ @@ -89,10 +88,10 @@ describe('ui-team browser plugin', () => { const t = b.ctx.locale.bind('agent-team') expect(t('trigger')).toBe('Agent Team') - b.actions().loadProjections(SESSION, { force: true }) - expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) + b.actions().loadProjections(SESSION) + expect(b.navigation).toEqual([['refresh', SESSION]]) b.actions().openTeammate(SESSION, LEAD) - expect(b.navigation).toEqual([['refresh', SESSION, { force: true }]]) + expect(b.navigation).toEqual([['refresh', SESSION]]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 0f71861236..2f41bc5a94 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -125,7 +125,7 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -192,7 +192,7 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) }) @@ -217,13 +217,13 @@ describe('TeamAction', () => { expect(screen.queryByRole('alert')).toBeNull() }) - it('refreshes an already-open Lead on every panel opening and retries failed reads', () => { + it('loads projections for an already-open Lead and retries failed reads', () => { const b = bench({ projections: {}, openState: 'open' }) render() expect(b.injected.loadProjections).not.toHaveBeenCalled() openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION) setProjectionSnapshot(b.sessions, SESSION, { state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {}, @@ -231,7 +231,7 @@ describe('TeamAction', () => { expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') fireEvent.click(screen.getByRole('button', { name: zh.retry })) expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) setProjectionSnapshot(b.sessions, SESSION, { state: 'loading', error: null, values: {} }) expect(screen.getByRole('status').textContent).toBe(zh.loading) @@ -244,7 +244,7 @@ describe('TeamAction', () => { expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) openPanel() expect(b.injected.loadProjections).toHaveBeenCalledTimes(3) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) }) it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { @@ -277,8 +277,7 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) expect(screen.getByText('Updated parent task')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION, { force: true }) - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(WORKER, { force: true }) + expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) }) it('shows capability absence after a successful read instead of loading forever', () => { @@ -299,7 +298,7 @@ describe('TeamAction', () => { expect(screen.getByText('Implement runtime')).toBeTruthy() expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION, { force: true }) + expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) }) it('loads active members models without opening their conversations', () => { @@ -397,7 +396,7 @@ describe('TeamAction', () => { openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead', { force: true }) + expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 6d51b921c0..8217db928c 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -201,10 +201,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a stable read-only source across same-id generations, with zero counts when none is live.', }, { - signature: 'refreshProjections(sessionId: SessionId, options?: SessionProjectionRefreshOptions): Promise', - description: 'Load all Session projections once per connection; retry unsuccessful reads.', - parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }, { name: 'options', description: 'force a fresh read even after a successful baseline; a running read finishes first.' }], - returns: 'completion of the requested read; removal, reconnect, or disposal cancels a queued refresh.', + signature: 'refreshProjections(sessionId: SessionId): Promise', + description: 'Load all Session projections once per connection; retry an unsuccessful initial read.', + parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }], + returns: 'completion of the current or newly started refresh.', }, { signature: 'search( query: string, signal: AbortSignal, ): Promise>', @@ -871,10 +871,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionMaybeStandardProps', declaration: 'export interface SessionMaybeStandardProps {\n}', }, - { - name: 'SessionProjectionRefreshOptions', - declaration: 'export interface SessionProjectionRefreshOptions {\n readonly force?: boolean;\n}', - }, { name: 'SessionProviderComponent', declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;', From ee0048558de046c175541b130feba66f218e4511 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 16:13:15 +0800 Subject: [PATCH 10/25] test(web): capture terminal recovery readiness with its snapshot --- apps/web/tests/sidebar-terminal.e2e.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index aac87b9f54..75ee794ebe 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -475,11 +475,16 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.context().setOffline(false) await page.reload({ waitUntil: 'load' }) await page.locator('[data-sidebar-right-expand]').click() - await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists') + const terminal = page.locator('[data-sidebar-terminal]') + let unavailableSnapshot = '' + // Mounting can refresh a failed recovered view; readiness and comparison use the same DOM sample. + await expect.poll(async () => { + unavailableSnapshot = await terminal.ariaSnapshot() + return unavailableSnapshot + }).toContain('no longer exists') expect(handles).toHaveLength(1) const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url)) - const terminal = page.locator('[data-sidebar-terminal]') - await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode()) + await compareOrRefreshGolden(unavailable, unavailableSnapshot, webSnapshotMode()) await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' }) const tabCount = await page.locator('[data-dockkit-tab]').count() const create = terminal.getByRole('button', { name: 'New terminal', exact: true }) From 9095678c8c3e61c5f832964eaf50eabb7ea30026 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 22 Sep 2026 22:21:10 +0800 Subject: [PATCH 11/25] fix(agent-team): address remaining projection review comments --- packages/experimental/agent-team/README.i18n.yaml | 4 ++-- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- packages/experimental/agent-team/src/task-view.ts | 2 +- packages/experimental/client-ui-agent-team/README.i18n.yaml | 4 ++-- packages/experimental/client-ui-agent-team/README.md | 3 ++- packages/experimental/client-ui-agent-team/README.zh.md | 3 ++- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 4b7b8f5ec1..696a104fa5 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 9c0a351cf6017bb026b864c643a2ebeaa19ccbdc -README.zh.md: 44a3ade10100d7d44d9ed9735cedfc75684fa2ae +README.md: 094e7a781ff01ebab1498b6ccd207814ae722dfb +README.zh.md: e6be6e5b500c4ca635eeb52f82a12e5e07faaaab diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 9c0a351cf6..094e7a781f 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. -The Web UI reads current-session values through the standard projection hook and overlays activity from Session status. The current Session supplies its baseline through conversation loading; other active members' baselines are loaded for durable model selection without opening their conversations. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. +The [Web UI](../client-ui-agent-team/README.md) owns projection baseline loading and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 44a3ade101..e6be6e5b50 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch `agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 -Web UI 通过标准投影钩子读取当前会话的值,并从 Session 状态叠加活动信息。当前 Session 通过会话加载提供基线;其他 active 成员的基线用于读取持久模型选择,无需打开它们的会话。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +[Web UI](../client-ui-agent-team/README.zh.md) 负责投影基线加载,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/agent-team/src/task-view.ts b/packages/experimental/agent-team/src/task-view.ts index a2f425782e..e0a5fe7002 100644 --- a/packages/experimental/agent-team/src/task-view.ts +++ b/packages/experimental/agent-team/src/task-view.ts @@ -10,7 +10,7 @@ import type { TeamTaskSnapshot, TeamTaskView } from './types.ts' * @param right - normalized write scope. * @returns whether either scope contains the other. */ -export function scopesOverlap(left: string, right: string): boolean { +function scopesOverlap(left: string, right: string): boolean { return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) } diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 435dcde232..d661eeb8c1 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: b6930b9cc893cbdf1a9734c8e81c260d23fabbb3 -README.zh.md: 93f9a0d036947bda994298967bc3574e136ce9f4 +README.md: 267c607f311500884e60d399a9e65ef0bb7fd868 +README.zh.md: 81d218379d3a5f370f7fbb6759b218cb3f92c47f diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index b6930b9cc8..267c607f31 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -64,7 +64,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, projection, and Remote behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and projection behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. @@ -84,6 +84,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la - **Cached capability absence** — if another UI loaded the Lead projection baseline before Agent Teams was enabled, opening this panel reuses that baseline; reconnect to load the newly enabled capability. +- **Member baseline failures** — the panel shows read errors only for the Lead. Failed reads for other active members retry when the panel reopens or receives a roster or task update; persistent failures can issue repeated requests while the Team is active. - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. - **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 93f9a0d036..81d218379d 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -64,7 +64,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task、投影与 Remote 行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与投影行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -84,6 +84,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati - **能力缺失的缓存** — 如果其他 UI 在启用 Agent Teams 前已读取 Lead 的投影基线,打开本面板会复用该基线;重新连接后才能加载新启用的能力。 +- **成员基线读取失败** — 面板只显示 Lead 的读取错误。其他 active 成员的读取失败会在重新打开面板或收到 roster、任务更新时重试;Team 活跃期间,持续失败可能反复发出请求。 - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 - **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 From 0edfe83573eaffb7257912a77cf8652716c92dff Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:33:49 -0700 Subject: [PATCH 12/25] fix(plugin-manager): bound GitHub connection checks before installation --- .../plugin-install-github/timeout.expected.md | 6 + apps/web/tests/plugin-install-github.e2e.ts | 52 +++++-- apps/web/tests/scaffold.ts | 3 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 5 +- docs/subsystems/boot.zh.md | 5 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 5 +- packages/boot/plugin-manager/README.zh.md | 5 +- .../plugin-manager/src/github-connection.ts | 62 ++++++++ packages/boot/plugin-manager/src/index.ts | 36 ++++- packages/boot/plugin-manager/src/types.ts | 4 +- .../tests/github-connection.spec.ts | 138 ++++++++++++++++++ .../boot/plugin-manager/tests/manager.spec.ts | 118 ++++++++++++++- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../src/client/PluginManagerPage.tsx | 2 +- .../ui-plugin-manager/src/client/locales.ts | 2 + .../tests/components.client.spec.tsx | 8 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- 24 files changed, 440 insertions(+), 51 deletions(-) create mode 100644 apps/web/tests/expected/plugin-install-github/timeout.expected.md create mode 100644 packages/boot/plugin-manager/src/github-connection.ts create mode 100644 packages/boot/plugin-manager/tests/github-connection.spec.ts diff --git a/apps/web/tests/expected/plugin-install-github/timeout.expected.md b/apps/web/tests/expected/plugin-install-github/timeout.expected.md new file mode 100644 index 0000000000..0d76ddbe98 --- /dev/null +++ b/apps/web/tests/expected/plugin-install-github/timeout.expected.md @@ -0,0 +1,6 @@ +- dialog "连接 GitHub 超时": + - heading "连接 GitHub 超时" [level=2] + - button "关闭" + - paragraph: 请尝试其他安装来源。 + - button "取消" + - button "改用国内镜像" diff --git a/apps/web/tests/plugin-install-github.e2e.ts b/apps/web/tests/plugin-install-github.e2e.ts index 1cc860d125..8fed48791d 100644 --- a/apps/web/tests/plugin-install-github.e2e.ts +++ b/apps/web/tests/plugin-install-github.e2e.ts @@ -1,6 +1,8 @@ -// The real installer classifies a controlled GitHub network failure; the browser +// Real Git reaches a controlled proxy failure or stall; the browser // offers a mirror for a replacement spec without retrying the failed address. +import { once } from 'node:events' import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { createServer, type Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,12 +11,33 @@ import { expect, it, onTestFinished } from 'vitest' import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' -it('offers a mirror after GitHub fails and waits for replacement input', async () => { +it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s and waits for replacement input', async (failure) => { const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-github-')) onTestFinished(() => rm(scratch, { recursive: true, force: true })) + const sockets = new Set() + let connections = 0 + const proxy = createServer((socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', () => { connections++; if (failure === 'network') socket.destroy() }) + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => proxy.close(error => error ? reject(error) : resolve())) + }) + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + const address = proxy.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + const gitConfig = join(scratch, 'git.config') + await writeFile(gitConfig, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`) const overlay = join(scratch, 'cordis.patch.yml') - await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`) - const scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay }) + await writeFile(overlay, '- id: ui-plugin-manager\n config: { registryProbeEnabled: false }\n') + const scaffold = await launchWebScaffold({ + profile: { packages: [], packageManager: { command: process.execPath, args: [], env: { GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1' } } }, + extraOverlayPath: overlay, + }) onTestFinished(() => scaffold.close()) const browser = await chromium.launch() onTestFinished(() => browser.close()) @@ -22,10 +45,11 @@ it('offers a mirror after GitHub fails and waits for replacement input', async ( const manifestPath = join(profile, 'package.json') const manifestBefore = await readFile(manifestPath, 'utf8') await writeFile(join(profile, 'config'), 'console.log("https://registry.npmjs.org/")\n') + await writeFile(join(profile, '.attempts'), '') await writeFile(join(profile, 'add'), ` const fs = require('node:fs'); fs.appendFileSync('.attempts', JSON.stringify(process.argv) + '\\n'); - console.error("fatal: unable to access 'https://github.com/example/dsh-plugin.git/': Could not resolve host: github.com"); + console.error('pnpm must not start after the GitHub connection check fails'); process.exitCode = 1; `) const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: ZH_BROWSER_LOCALE }) @@ -36,21 +60,25 @@ it('offers a mirror after GitHub fails and waits for replacement input', async ( await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() await page.getByRole('button', { name: '添加插件', exact: true }).click() const spec = 'https://github.com/example/dsh-plugin.git' + const title = failure === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' let dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) await dialog.getByRole('button', { name: '安装源 默认安装源', exact: true }).waitFor() await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec) - expect(await page.getByText('无法访问 GitHub', { exact: true }).count()).toBe(0) + expect(await page.getByText(title, { exact: true }).count()).toBe(0) await dialog.getByRole('button', { name: '安装', exact: true }).click() - dialog = page.getByRole('dialog', { name: '无法访问 GitHub', exact: true }) + dialog = page.getByRole('dialog', { name: title, exact: true }) await dialog.waitFor() expect(await page.getByRole('dialog').count()).toBe(1) expect(await dialog.getByText('请尝试其他安装来源。', { exact: true }).count()).toBe(1) await compareOrRefreshGolden( - fileURLToPath(new URL('./expected/plugin-install-github/failed.expected.md', import.meta.url)), + fileURLToPath(new URL(`./expected/plugin-install-github/${failure === 'timeout' ? 'timeout' : 'failed'}.expected.md`, import.meta.url)), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), ) const attempts = await readFile(join(profile, '.attempts'), 'utf8') - expect(attempts.trim().split('\n')).toHaveLength(1) + expect(attempts).toBe('') + expect(connections).toBeGreaterThan(0) + await expect.poll(() => sockets.size).toBe(0) + const checked = connections await dialog.getByRole('button', { name: '改用国内镜像', exact: true }).click() dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) const input = dialog.getByRole('textbox', { name: '插件包名', exact: true }) @@ -64,12 +92,14 @@ it('offers a mirror after GitHub fails and waits for replacement input', async ( await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), ) expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe(attempts) + expect(connections).toBe(checked) expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore) await input.fill(spec) await dialog.getByRole('button', { name: '安装', exact: true }).click() - dialog = page.getByRole('dialog', { name: '无法访问 GitHub', exact: true }) + dialog = page.getByRole('dialog', { name: title, exact: true }) await dialog.getByRole('button', { name: '取消', exact: true }).click() await dialog.waitFor({ state: 'detached' }) - expect((await readFile(join(profile, '.attempts'), 'utf8')).trim().split('\n')).toHaveLength(2) + expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe('') + expect(connections).toBeGreaterThan(checked) expect(tripwire.pageErrors).toEqual([]) }) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6e18a9c3ed..9fde2ea346 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -334,6 +334,8 @@ export interface LaunchOptions { */ profile?: { hmr?: boolean + /** Launcher-owned invocation and environment for profile package operations. */ + packageManager?: ProfileContext['packageManager'] packages: { dir: string; enabled?: boolean }[] /** Additional selected names, including bundles unavailable after an upgrade. */ bundles?: readonly string[] @@ -772,6 +774,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise layer.packageName), overlays: processOverlays, telemetryDisabledEnv: undefined, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 106db59c55..4a632164fd 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: b9e75f6679d43aef41808a30ed370e26070c1331 -config-catalog.zh.md: d76c88fb806998abee414347822a3d11c3b2b7de +config-catalog.md: 2668f1f933654526c274f26eb4543a7d7085e808 +config-catalog.zh.md: 97509f6162b6804562390525a5e1031150692f5e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b9e75f6679..2668f1f933 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2165,16 +2165,18 @@ Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/s Requires: `loader` · `profileContext` ```ts config-catalog -/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */ +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ pnpmCommand?: string - /** Maximum retained pnpm diagnostic bytes per operation. */ + /** Maximum retained package-operation diagnostic bytes. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number /** Bound on one registry lookup an inspection runs, in milliseconds. */ inspectTimeoutMs?: number + /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ + githubConnectionTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -2186,7 +2188,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d76c88fb80..97509f6162 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2167,16 +2167,18 @@ export interface PlanModeConfig { 需要: `loader` · `profileContext` ```ts config-catalog -/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */ +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ pnpmCommand?: string - /** Maximum retained pnpm diagnostic bytes per operation. */ + /** Maximum retained package-operation diagnostic bytes. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number /** Bound on one registry lookup an inspection runs, in milliseconds. */ inspectTimeoutMs?: number + /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ + githubConnectionTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -2188,7 +2190,7 @@ export interface Config { } ``` -来源: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts) +来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 00cd046aaa..0548f68612 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: a1e8917adaf80315d871081740e5a76e5cd1e6dd -boot.zh.md: 1ba455d6ba9ff0053ff9d6f6d1313e7ae871a2a1 +boot.md: 41729df9ebb66354997b4fc75d8fc0e68cf90902 +boot.zh.md: 759679d82894f6e739a2a91d84f862fe92448b32 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index a1e8917ada..41729df9eb 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -129,7 +129,8 @@ Manage profile files and apply their declared reload lifecycle. @Remote setBundleEnabled(name: string, enabled: boolean): Promise /** - * Install a package using the same pnpm implementation as dsh plugin. A run + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -148,7 +149,7 @@ Manage profile files and apply their declared reload lifecycle. /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. - * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being * applied, `not-running` for any other id. */ @Remote async cancelInstall(requestId: PluginInstallRequestId): Promise diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1ba455d6ba..759679d828 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -129,7 +129,8 @@ Manage profile files and apply their declared reload lifecycle. @Remote setBundleEnabled(name: string, enabled: boolean): Promise /** - * Install a package using the same pnpm implementation as dsh plugin. A run + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -148,7 +149,7 @@ Manage profile files and apply their declared reload lifecycle. /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. - * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being * applied, `not-running` for any other id. */ @Remote async cancelInstall(requestId: PluginInstallRequestId): Promise diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 8dbbf1d7b8..758ae03a96 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: 1de9c9d78e421d44eb006f1ab7fd82656f006a2a -README.zh.md: f7502aab64738ad1feccf58b84ff945213df2eff +README.md: a27fd4334d21046c230555f85233da1b7a2ac489 +README.zh.md: 4a7e8b1901101ad63dc5fdcb5af705ed27d7cf86 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 1de9c9d78e..a27fd4334d 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -45,11 +45,13 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. +`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer’s Git environment and configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. A failed check prevents installation; network failures and timeouts report `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. + Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale. The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries. -`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. +`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. `waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation. @@ -61,6 +63,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe |---|---|---| | `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. | | `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. | +| `githubConnectionTimeoutMs` | `5000` | Deadline for the GitHub repository check before installation, in milliseconds. | | `registry` | pnpm's own | The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. | | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. | | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. | diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index f7502aab64..4a7e8b1901 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -45,11 +45,13 @@ kind: "package-reference" `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 +`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 环境与配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查失败即停止安装;网络失败和超时通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 + 注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。 可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。 -`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 +`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 `waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。 @@ -61,6 +63,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro |---|---|---| | `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 | | `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 | +| `githubConnectionTimeoutMs` | `5000` | 安装前 GitHub 仓库连接检查的时限,单位毫秒。 | | `registry` | pnpm 自身配置 | 查询与安装首先询问的注册表,http(s) URL;缺省为 pnpm 自身配置指定的那个。 | | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 | | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 | diff --git a/packages/boot/plugin-manager/src/github-connection.ts b/packages/boot/plugin-manager/src/github-connection.ts new file mode 100644 index 0000000000..29c169a0df --- /dev/null +++ b/packages/boot/plugin-manager/src/github-connection.ts @@ -0,0 +1,62 @@ +/** Bounded GitHub repository checks using the installer's Git configuration and environment. */ +import { mkdir, mkdtemp, open } from 'node:fs/promises' +import { join } from 'node:path' +import { execa } from 'execa' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { classifyInstallFailure } from './install-failure.ts' +import type { ParsedInstallSpec } from './install-spec.ts' +import type { PackageResult } from './types.ts' + +/** Deadline, diagnostics and cancellation for one read-only GitHub check. */ +export interface GithubConnectionOptions { + timeoutMs: number + outputBytes: number + signal: AbortSignal + /** The application-owned package manager's environment, also used by its Git children. */ + env?: Readonly> +} + +/** + * Check a GitHub repository before pnpm starts, without downloading or building its package. + * Git reads the profile's normal Git and proxy configuration; timeout and cancellation terminate its descendants too. + * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. + * @param dir The profile directory where installation runs. + * @param options The connection deadline, output bound and operation cancellation. + * @returns A failed check with a complete diagnostic log, or undefined for a reachable repository or an unhandled spec. + */ +export async function checkGithubConnection( + spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions, +): Promise { + if (spec.kind !== 'git') return undefined + const host = spec.host.toLowerCase().replace(/:.*$/, '') + if (host !== 'github.com' && !host.endsWith('.github.com')) return undefined + const repository = spec.spec.replace(/#.*$/s, '') + .replace(/^github:/i, 'https://github.com/') + .replace(/^gist:/i, 'https://gist.github.com/') + .replace(/^git\+/i, '') + const logRoot = join(dir, '.plugin-manager', 'logs') + await mkdir(logRoot, { recursive: true, mode: 0o700 }) + const logDir = await mkdtemp(join(logRoot, 'github-connection-')) + const logPath = join(logDir, 'git.log') + const log = await open(logPath, 'ax+', 0o600) + try { + const result = await execa('git', ['ls-remote', '--', repository, 'HEAD'], { + cwd: dir, env: { ...scrubbedParentEnv(), ...options.env, GIT_TERMINAL_PROMPT: '0' }, extendEnv: false, + stdin: 'ignore', stdout: 'ignore', stderr: { file: logPath, append: true }, buffer: false, reject: false, + timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL', + }) + if (!result.failed) return undefined + if (result.timedOut) await log.write(`fatal: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`) + if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'GitHub connection check failed') + const { size } = await log.stat() + const bytes = Buffer.alloc(Math.min(size, options.outputBytes)) + await log.read(bytes, 0, bytes.length, size - bytes.length) + const output = bytes.toString('utf8') + return { + exitCode: result.exitCode ?? 1, output, truncated: size > options.outputBytes, logPath, + kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }), + } + } finally { + await log.close() + } +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 876adf9750..dd2f5c5669 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -24,6 +24,7 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } import { writePluginEnabled } from './patch.ts' import { ManagementFailure } from './failure.ts' import { approveBuilds, readPendingBuilds } from './build-approval.ts' +import { checkGithubConnection } from './github-connection.ts' import type { BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, @@ -33,16 +34,18 @@ export type * from './types.ts' export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts' export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' -/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */ +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ pnpmCommand?: string - /** Maximum retained pnpm diagnostic bytes per operation. */ + /** Maximum retained package-operation diagnostic bytes. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number /** Bound on one registry lookup an inspection runs, in milliseconds. */ inspectTimeoutMs?: number + /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ + githubConnectionTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -172,6 +175,7 @@ export class PluginManager extends TypertRemoteService { outputBytes: z.number().step(1).min(1).default(16384), lockWaitMs: z.number().step(1).min(0).default(120000), inspectTimeoutMs: z.number().step(1).min(1000).default(20000), + githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000), registry: z.string().pattern(REGISTRY_URL), fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), }) @@ -183,6 +187,7 @@ export class PluginManager extends TypertRemoteService { private readonly outputBytes: number private readonly lockWaitMs: number private readonly inspectTimeoutMs: number + private readonly githubConnectionTimeoutMs: number private readonly pnpmCommand: string private readonly configuredRegistries: Omit private readonly ownerContext: Context @@ -199,6 +204,7 @@ export class PluginManager extends TypertRemoteService { this.outputBytes = (config as Required).outputBytes this.lockWaitMs = (config as Required).lockWaitMs this.inspectTimeoutMs = (config as Required).inspectTimeoutMs + this.githubConnectionTimeoutMs = (config as Required).githubConnectionTimeoutMs this.pnpmCommand = (config as Required).pnpmCommand this.configuredRegistries = { registry: config.registry === undefined ? null : normalizeRegistry(config.registry), @@ -406,7 +412,8 @@ export class PluginManager extends TypertRemoteService { } /** - * Install a package using the same pnpm implementation as dsh plugin. A run + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -432,12 +439,27 @@ export class PluginManager extends TypertRemoteService { } const files = await this.readRestoredFiles() const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} - const plan = registryPlan(options?.registry, await this.registries()) let name: string try { - // The last run is the result's; the registries asked stay listed whatever the outcome. - let run: PackageResult | undefined result.registries = [] + const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, { + timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes, + signal: AbortSignal.any([this.abort.signal, control.abort.signal]), + ...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env }, + }) + this.packageOperations.add(connection) + let connectionFailure: PackageResult | undefined + try { connectionFailure = await connection } + finally { this.packageOperations.delete(connection) } + if (stopped()) throw new InstallCancelledError() + if (connectionFailure !== undefined) { + result.packageResult = connectionFailure + if (connectionFailure.kind === 'network' || connectionFailure.kind === 'timeout') result.failedAt = 'spec-host' + throw new Error(connectionFailure.output) + } + // The last run is the result's; the registries asked stay listed whatever the outcome. + const plan = registryPlan(options?.registry, await this.registries()) + let run: PackageResult | undefined for (const [index, registry] of plan.entries()) { if (index > 0) await this.restoreFiles(files) // A stop that landed while the files went back, or before the first run, starts no run with a dead signal. @@ -509,7 +531,7 @@ export class PluginManager extends TypertRemoteService { /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. - * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being * applied, `not-running` for any other id. */ @Remote diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 7743b4f35f..4eb78b96f6 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -69,7 +69,7 @@ export interface PluginRegistries { readonly resolved: string | null } -/** How a pnpm run failed, read off how it ended and what it printed. */ +/** How a package operation failed, read off how it ended and what it printed. */ export type PluginInstallFailureKind = | 'pnpm-missing' | 'timeout' @@ -82,7 +82,7 @@ export type PluginInstallFailureKind = | 'integrity' | 'unknown' -/** Pnpm completion, including a retrieval path for unabridged diagnostics. */ +/** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */ export interface PackageResult { exitCode: number output: string diff --git a/packages/boot/plugin-manager/tests/github-connection.spec.ts b/packages/boot/plugin-manager/tests/github-connection.spec.ts new file mode 100644 index 0000000000..d68ac219d5 --- /dev/null +++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts @@ -0,0 +1,138 @@ +/** Real Git transport deadlines, process cleanup, and bounded connection diagnostics. */ +import { once } from 'node:events' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { appendFileSync } from 'node:fs' +import { createServer, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import * as subprocess from 'execa' +import { expect, it, onTestFinished, vi } from 'vitest' +import { checkGithubConnection } from '../src/github-connection.ts' +import { parseInstallSpec } from '../src/install-spec.ts' + +vi.mock('execa', async (original) => { + const module = await original() + return { ...module, execa: vi.fn(module.execa) } +}) + +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), 'dsh-github-check-')) + onTestFinished(() => rm(dir, { recursive: true, force: true })) + const controller = new AbortController() + const tasks: Promise[] = [] + onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) }) + const config = join(dir, 'git.config') + await writeFile(config, '') + const check = (spec: string, timeoutMs = 3000, outputBytes = 4096) => { + const task = checkGithubConnection(parseInstallSpec(spec), dir, { + timeoutMs, outputBytes, signal: controller.signal, + env: { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }, + }) + tasks.push(task) + return task + } + return { dir, config, controller, check } +} + +async function proxy(config: string, fail: boolean) { + const sockets = new Set() + const connected = Promise.withResolvers() + const disconnected = Promise.withResolvers() + const server = createServer((socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.once('close', () => { sockets.delete(socket); disconnected.resolve(undefined) }) + socket.once('data', () => { connected.resolve(undefined); if (fail) socket.destroy() }) + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + await writeFile(config, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`) + return { connected: connected.promise, disconnected: disconnected.promise, sockets } +} + +it('leaves registry packages and other Git hosts to the installer', async () => { + const { check, dir } = await fixture() + expect(await check('@acme/plugin')).toBeUndefined() + expect(await check('https://gitlab.com/acme/plugin.git')).toBeUndefined() + await expect(readFile(join(dir, '.plugin-manager', 'logs'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([ + ['https://github.com/acme/plugin.git#main', 'https://github.com/'], + ['github:acme/plugin#main', 'https://github.com/'], + ['git+https://github.com/acme/plugin.git#main', 'https://github.com/'], + ['git+ssh://git@github.com:22/acme/plugin.git#main', 'ssh://git@github.com:22/'], + ['git@github.com:acme/plugin.git#main', 'git@github.com:'], + ['gist:acme/plugin#main', 'https://gist.github.com/'], +])('checks %s through the profile Git configuration without downloading a package', async (spec, prefix) => { + const { check, dir, config } = await fixture() + const repository = join(dir, 'empty.git') + await subprocess.execa('git', ['init', '--bare', repository]) + // Match both forms exactly; a fragment reaching Git would fail to resolve this repository. + await writeFile(config, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ${prefix}acme/plugin.git\n insteadOf = ${prefix}acme/plugin\n`) + expect(await check(spec)).toBeUndefined() +}) + +it('reports an immediate transport failure without waiting for the deadline', async () => { + const { check, config } = await fixture() + const transport = await proxy(config, true) + const result = await check('https://github.com/acme/plugin.git') + await transport.connected + expect(result).toMatchObject({ exitCode: 128, kind: 'network', truncated: false }) + expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) + await transport.disconnected + expect(transport.sockets.size).toBe(0) +}) + +it('ends a silent proxy connection and its Git child at the connection deadline', async () => { + const { check, config } = await fixture() + const transport = await proxy(config, false) + const running = check('https://github.com/acme/plugin.git') + await transport.connected + const result = await running + expect(result?.kind).toBe('timeout') + expect(result!.output).toContain('connection to github.com timed out after 3000ms') + await transport.disconnected + expect(transport.sockets.size).toBe(0) +}) + +it('cancels a connected Git probe and closes its transport before the deadline', async () => { + const { check, config, controller } = await fixture() + const transport = await proxy(config, false) + const running = check('https://github.com/acme/plugin.git', 60000) + await transport.connected + controller.abort() + const result = await running + expect(result?.kind).toBe('unknown') + await transport.disconnected + expect(transport.sockets.size).toBe(0) +}) + +it.each([undefined, 'Git could not start'])('retains a diagnostic when Git writes no stderr (%s)', async (shortMessage) => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, shortMessage } as Awaited>) + onTestFinished(() => run.mockRestore()) + const result = await check('github:acme/plugin') + expect(result).toMatchObject({ exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed' }) + expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) +}) + +it('bounds returned diagnostics while retaining Git stderr in the operation log', async () => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockImplementation((...args: unknown[]) => { + const options = args[2] as { stderr: { file: string } } + appendFileSync(options.stderr.file, '0123456789') + return Promise.resolve({ failed: true, exitCode: 128 }) as ReturnType + }) + onTestFinished(() => run.mockRestore()) + const result = await check('github:acme/plugin', 3000, 4) + expect(result).toMatchObject({ output: '6789', truncated: true }) + expect(await readFile(result!.logPath, 'utf8')).toBe('0123456789') +}) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 16fbed9650..c08ff6eec1 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -4,6 +4,7 @@ import { realpath } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' import type { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' import { @@ -17,6 +18,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { Group } from '@deepseek-ai/cordis-plugin-loader' import * as operations from '../src/operations.ts' +import * as githubConnection from '../src/github-connection.ts' import { parse, parseDocument } from 'yaml' async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) { @@ -60,7 +62,8 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre }) // What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry. const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/') - onTestFinished(() => { registry.mockRestore() }) + const connection = vi.spyOn(githubConnection, 'checkGithubConnection').mockResolvedValue(undefined) + onTestFinished(() => { registry.mockRestore(); connection.mockRestore() }) let stopHmr = async () => {} if (reload === 'live') { await ctx.plugin(Timer) @@ -68,9 +71,120 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre stopHmr = () => owner.dispose() await ctx.hmr.runExclusive(async () => {}) } - return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays } + return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection } } +it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => { + const { manager, dir, connection } = await fixture() + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const failure = { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind } + connection.mockResolvedValue(failure) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => pnpm.mockRestore()) + expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({ + application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', packageResult: failure, + }) + expect(pnpm).not.toHaveBeenCalled() + expect(connection).toHaveBeenCalledWith(expect.objectContaining({ host: 'github.com' }), dir, expect.objectContaining({ timeoutMs: 5000 })) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + +it('keeps non-network GitHub errors out of mirror recovery and forwards profile Git environment and deadline', async () => { + const env = { GIT_CONFIG_GLOBAL: '/application/git.config' } + const { manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env }) + connection.mockResolvedValue({ exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown' }) + const result = await manager.installBundle('github:acme/private-plugin') + expect(result.application).toBe('failed') + expect(result.failedAt).toBeUndefined() + expect(connection).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ timeoutMs: 8000, env })) +}) + +it('cancels an active GitHub check before starting pnpm', async () => { + const { manager, connection } = await fixture() + const entered = Promise.withResolvers() + connection.mockImplementation(async (_spec, _dir, options) => { + entered.resolve(undefined) + await new Promise(resolve => options.signal.addEventListener('abort', () => resolve(), { once: true })) + return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } + }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => pnpm.mockRestore()) + const requestId = '824103ec-bc45-489d-bb85-5b4fe0aefc78' as PluginInstallRequestId + const installing = manager.installBundle('github:acme/dsh-plugin', { requestId }) + await entered.promise + expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' }) + expect(await installing).toMatchObject({ application: 'cancelled', changed: false }) + expect(pnpm).not.toHaveBeenCalled() +}) + +it('waits for the GitHub check before installing and activating the bundle', async () => { + const { manager, dir, bundle, connection } = await fixture() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + connection.mockImplementation(async () => { entered.resolve(undefined); await release.promise; return undefined }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + bundle('addon', []) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + const installing = manager.installBundle('github:acme/addon') + onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() }) + await entered.promise + expect(pnpm).not.toHaveBeenCalled() + release.resolve(undefined) + expect(await installing).toMatchObject({ application: 'applied', changed: true, bundle: 'addon' }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon') +}) + +it('disposal waits for an active GitHub check to stop', async () => { + const { ctx, manager, connection } = await fixture('startup') + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + connection.mockImplementation(async (_spec, _dir, options) => { + entered.resolve(undefined) + await new Promise(resolve => options.signal.addEventListener('abort', () => resolve(), { once: true })) + released.resolve(undefined) + return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } + }) + const installing = manager.installBundle('github:acme/addon') + await entered.promise + await ctx.fiber.dispose() + await released.promise + expect(await installing).toMatchObject({ application: 'failed', changed: false }) +}) + +it('installs through real Git and pnpm after a successful GitHub connection check', async () => { + const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url)) + const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' } + const { manager, dir, connection } = await fixture('startup', false, undefined, {}, { + command: process.execPath, args: ['--expose-internals', pnpm], env, + }) + connection.mockRestore() + const repository = join(dir, 'repository') + mkdirSync(repository) + env.GIT_CONFIG_GLOBAL = join(dir, 'git.config') + writeFileSync(env.GIT_CONFIG_GLOBAL, '') + const git = (args: string[]) => execa('git', args, { cwd: repository, env }) + await git(['init', '--initial-branch=main']) + const name = '@test/github-connected' + writeFileSync(join(repository, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n') + await git(['add', '.']) + await git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle']) + writeFileSync(env.GIT_CONFIG_GLOBAL, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ssh://git@github.com/acme/connected.git\n insteadOf = git@github.com:acme/connected.git\n`) + // SSH keeps pnpm on Git transport; Git's URL rewrite makes this success path entirely local. + const manifest = readProfileManifest('test', dir) + delete manifest.dependencies + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + writeFileSync(join(dir, '.npmrc'), `store-dir=${join(dir, 'store').replaceAll('\\', '/')}\n`) + const result = await manager.installBundle('git+ssh://git@github.com/acme/connected.git', { enabled: false }) + expect(result.error).toBeUndefined() + expect(result).toMatchObject({ changed: true, bundle: name, packageResult: { exitCode: 0 } }) + expect(readProfileManifest('test', dir).dependencies).toHaveProperty(name) +}) + it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])( 'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => { const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => { diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 568cf01093..0565efde3e 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: d89e56a1115d077865c49150ecbf4991a524cfe9 -README.zh.md: 50699e47490425f34eb295bec057ea970b84b7e5 +README.md: ceedcd69eabb90a59db99bcc244cff7bb1a98c84 +README.zh.md: f3c3d9e922dcd6bf8283287fb0e4aa9970fe390d diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d89e56a111..ceedcd69ea 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -39,7 +39,7 @@ The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https:// During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. -When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub** and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself. +When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub**, or **GitHub connection timed out** for a timeout, and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself. ### Switching a bundle diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 50699e4749..f3c3d9e922 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -39,7 +39,7 @@ Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 -Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。 +Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,超时时显示**连接 GitHub 超时**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。 ### 切换一个组合包 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index bcc9ff0d40..3140b0c296 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -785,7 +785,7 @@ function InstallDialog({ { expect(screen.getByText(en.installSubjectTarball)).toBeTruthy() }) - it('shows GitHub recovery only after a connection failure and returns to package input', () => { + it.each(['network', 'timeout'] as const)('shows GitHub recovery only after a %s failure and returns to package input', (kind) => { const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' } as const const failed: InstallState = { ...IDLE_INSTALL, open: true, spec: subject.spec, registries: REGISTRIES, phase: 'failed', subject, - failure: { reason: 'Could not resolve host: github.com', kind: 'network', failedAt: 'spec-host' }, + failure: { reason: 'GitHub connection failed', kind, failedAt: 'spec-host' }, } const { actions, set, setLanguage } = renderTab({ install: { ...failed, phase: 'idle', failure: null } }) expect(screen.queryByText(en.installGithubFailedTitle)).toBeNull() set({ install: failed }) - const dialog = screen.getByRole('dialog', { name: en.installGithubFailedTitle }) + const dialog = screen.getByRole('dialog', { name: kind === 'timeout' ? en.installGithubTimeoutTitle : en.installGithubFailedTitle }) expect(within(dialog).getByText(en.installGithubFailedDescription)).toBeTruthy() expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull() fireEvent.click(within(dialog).getByRole('button', { name: en.installUseGithubMirror })) @@ -1075,7 +1075,7 @@ describe('PluginManagerPage', () => { fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) expect(actions.closeInstall).toHaveBeenCalledOnce() setLanguage(zh) - expect(screen.getByRole('dialog', { name: '无法访问 GitHub' })).toBeTruthy() + expect(screen.getByRole('dialog', { name: kind === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' })).toBeTruthy() expect(screen.getByText('请尝试其他安装来源。')).toBeTruthy() set({ install: { ...IDLE_INSTALL, open: true, mirrorRecovery: true, registries: REGISTRIES, registry: { kind: 'offered', registry: MIRROR } } }) const form = within(screen.getByRole('dialog', { name: zh.installTitle })) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 65bb485075..f503e4d186 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1629,7 +1629,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise', - description: 'Install a package using the same pnpm implementation as dsh plugin. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.', + description: 'Install a package using the same pnpm implementation as dsh plugin. GitHub repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.', parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.' }], returns: 'Package-manager diagnostics, the registries asked, and the observed activation outcome.', }, @@ -1643,7 +1643,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: '@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise', description: 'Stop an installation this manager owns and wait until its files are back.', parameters: [{ name: 'requestId', description: 'The id the installation was started with.' }], - returns: '`cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being applied, `not-running` for any other id.', + returns: '`cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being applied, `not-running` for any other id.', }, { signature: '@Remote removeBundle(name: string): Promise', From 75d74a055e7c8e9b065d1d31ece57c67ccc78c23 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:15:09 -0700 Subject: [PATCH 13/25] fix(plugin-manager): preserve authentication fallback during GitHub checks --- ...09-15-guided-plugin-installation.i18n.yaml | 4 +- .../2026-09-15-guided-plugin-installation.md | 6 +- ...026-09-15-guided-plugin-installation.zh.md | 6 +- apps/web/tests/plugin-install-github.e2e.ts | 4 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 3 +- docs/subsystems/boot.zh.md | 3 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- .../plugin-manager/src/github-connection.ts | 18 ++- packages/boot/plugin-manager/src/index.ts | 9 +- .../plugin-manager/src/install-failure.ts | 1 + .../tests/github-connection.spec.ts | 55 ++++++++- .../plugin-manager/tests/install-spec.spec.ts | 4 + .../boot/plugin-manager/tests/manager.spec.ts | 108 ++++++++++++------ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 17 files changed, 170 insertions(+), 65 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index 5c2d10151d..a5b7444619 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md -2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be -2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe +2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343 +2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index 26ad870856..451b1f316e 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -16,6 +16,8 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. +**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling. + **Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for. **Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed. @@ -36,8 +38,10 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina ## Consequences -`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card. +`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand`, `inspectTimeoutMs` and `githubConnectionTimeoutMs`. The dialog is four screens over one subject card. ## Testing `packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry. + +`github-connection.spec.ts` uses real Git against owned loopback transports to cover immediate failure, timeout, cancellation and noninteractive authentication. `manager.spec.ts` also installs a local repository through real Git and pnpm for HTTPS, GitHub shorthand and SSH specs, isolating pnpm's HTTPS probe through an owned proxy and verifying its SSH fallback. `plugin-install-github.e2e.ts` checks the connection error and timeout dialogs, child cleanup, and switching to a mirror without retrying the old GitHub address. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index 36e76c8ace..0c22869582 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -16,6 +16,8 @@ Status: implemented **失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 +**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 + **取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。 **启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。 @@ -36,8 +38,10 @@ Status: implemented ## 后果 -`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。 +`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand`、`inspectTimeoutMs` 与 `githubConnectionTimeoutMs`。对话框是围绕同一张主题卡的四个画面。 ## 测试 `packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。 + +`github-connection.spec.ts` 用真实 Git 和测试自有的本机网络端点覆盖立即失败、超时、取消与非交互认证。`manager.spec.ts` 还通过真实 Git 与 pnpm 安装本地仓库,覆盖 HTTPS、GitHub 简写和 SSH 地址,用自有代理隔离 pnpm 的 HTTPS 请求并验证其 SSH 回退。`plugin-install-github.e2e.ts` 检查连接失败及超时弹窗、子进程清理,以及切换镜像时不重试原 GitHub 地址。 diff --git a/apps/web/tests/plugin-install-github.e2e.ts b/apps/web/tests/plugin-install-github.e2e.ts index 8fed48791d..1926b6f4e2 100644 --- a/apps/web/tests/plugin-install-github.e2e.ts +++ b/apps/web/tests/plugin-install-github.e2e.ts @@ -19,12 +19,12 @@ it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s and const proxy = createServer((socket) => { sockets.add(socket) socket.on('error', () => {}) - socket.once('close', () => sockets.delete(socket)) + socket.once('close', () => { sockets.delete(socket) }) socket.once('data', () => { connections++; if (failure === 'network') socket.destroy() }) }) onTestFinished(async () => { for (const socket of sockets) socket.destroy() - await new Promise((resolve, reject) => proxy.close(error => error ? reject(error) : resolve())) + await new Promise((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) }) }) proxy.listen(0, '127.0.0.1') await once(proxy, 'listening') diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 0548f68612..4df5918665 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 41729df9ebb66354997b4fc75d8fc0e68cf90902 -boot.zh.md: 759679d82894f6e739a2a91d84f862fe92448b32 +boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7 +boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 41729df9eb..6ec2204ffe 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -130,7 +130,8 @@ Manage profile files and apply their declared reload lifecycle. /** * Install a package using the same pnpm implementation as dsh plugin. GitHub - * repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 759679d828..f5fde00f0c 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -130,7 +130,8 @@ Manage profile files and apply their declared reload lifecycle. /** * Install a package using the same pnpm implementation as dsh plugin. GitHub - * repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 758ae03a96..a27c97fc81 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: a27fd4334d21046c230555f85233da1b7a2ac489 -README.zh.md: 4a7e8b1901101ad63dc5fdcb5af705ed27d7cf86 +README.md: 803566c8650df7186c5344945ea0fe2359e8e70c +README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index a27fd4334d..803566c865 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -45,7 +45,7 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. -`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer’s Git environment and configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. A failed check prevents installation; network failures and timeouts report `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. +`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 4a7e8b1901..657e4587e2 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 -`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 环境与配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查失败即停止安装;网络失败和超时通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 +`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。 diff --git a/packages/boot/plugin-manager/src/github-connection.ts b/packages/boot/plugin-manager/src/github-connection.ts index 29c169a0df..99b64fd73c 100644 --- a/packages/boot/plugin-manager/src/github-connection.ts +++ b/packages/boot/plugin-manager/src/github-connection.ts @@ -18,11 +18,12 @@ export interface GithubConnectionOptions { /** * Check a GitHub repository before pnpm starts, without downloading or building its package. - * Git reads the profile's normal Git and proxy configuration; timeout and cancellation terminate its descendants too. + * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting. + * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback. * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. * @param dir The profile directory where installation runs. * @param options The connection deadline, output bound and operation cancellation. - * @returns A failed check with a complete diagnostic log, or undefined for a reachable repository or an unhandled spec. + * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec. */ export async function checkGithubConnection( spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions, @@ -40,20 +41,25 @@ export async function checkGithubConnection( const logPath = join(logDir, 'git.log') const log = await open(logPath, 'ax+', 0o600) try { - const result = await execa('git', ['ls-remote', '--', repository, 'HEAD'], { - cwd: dir, env: { ...scrubbedParentEnv(), ...options.env, GIT_TERMINAL_PROMPT: '0' }, extendEnv: false, + const result = await execa('git', ['-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'], { + cwd: dir, + env: { + ...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', + }, + extendEnv: false, stdin: 'ignore', stdout: 'ignore', stderr: { file: logPath, append: true }, buffer: false, reject: false, timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL', }) if (!result.failed) return undefined - if (result.timedOut) await log.write(`fatal: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`) + if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`) if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'GitHub connection check failed') const { size } = await log.stat() const bytes = Buffer.alloc(Math.min(size, options.outputBytes)) await log.read(bytes, 0, bytes.length, size - bytes.length) const output = bytes.toString('utf8') return { - exitCode: result.exitCode ?? 1, output, truncated: size > options.outputBytes, logPath, + exitCode: result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1), output, truncated: size > options.outputBytes, logPath, kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }), } } finally { diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index dd2f5c5669..93d0287b9e 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -413,7 +413,8 @@ export class PluginManager extends TypertRemoteService { /** * Install a package using the same pnpm implementation as dsh plugin. GitHub - * repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -425,7 +426,7 @@ export class PluginManager extends TypertRemoteService { installBundle(spec: string, options?: InstallBundleOptions): Promise { const requestId = options?.requestId const control: InstallControl = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) } - const stopped = (): boolean => control.abort.signal.aborted + const stopped = (): boolean => control.abort.signal.aborted || this.abort.signal.aborted if (requestId !== undefined) this.installs.set(requestId, control) const announce = (phase: PluginInstallProgress['phase'], attempt?: PluginInstallProgress['attempt']): void => { if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase, ...attempt === undefined ? {} : { attempt } }) @@ -452,9 +453,9 @@ export class PluginManager extends TypertRemoteService { try { connectionFailure = await connection } finally { this.packageOperations.delete(connection) } if (stopped()) throw new InstallCancelledError() - if (connectionFailure !== undefined) { + if (connectionFailure?.kind === 'network' || connectionFailure?.kind === 'timeout') { result.packageResult = connectionFailure - if (connectionFailure.kind === 'network' || connectionFailure.kind === 'timeout') result.failedAt = 'spec-host' + result.failedAt = 'spec-host' throw new Error(connectionFailure.output) } // The last run is the result's; the registries asked stay listed whatever the outcome. diff --git a/packages/boot/plugin-manager/src/install-failure.ts b/packages/boot/plugin-manager/src/install-failure.ts index 47a68c920a..009eeb1ad9 100644 --- a/packages/boot/plugin-manager/src/install-failure.ts +++ b/packages/boot/plugin-manager/src/install-failure.ts @@ -25,6 +25,7 @@ const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [ ['disk-full', /\bENOSPC\b|no space left on device/i], ['permission', /\bEACCES\b|\bEPERM\b|permission denied/i], ['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/], + ['unknown', /The requested URL returned error: 40[134]\b/], ['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/], ] diff --git a/packages/boot/plugin-manager/tests/github-connection.spec.ts b/packages/boot/plugin-manager/tests/github-connection.spec.ts index d68ac219d5..673fb7c733 100644 --- a/packages/boot/plugin-manager/tests/github-connection.spec.ts +++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts @@ -3,6 +3,7 @@ import { once } from 'node:events' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { appendFileSync } from 'node:fs' import { createServer, type Socket } from 'node:net' +import { createServer as createHttpServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -24,15 +25,16 @@ async function fixture() { onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) }) const config = join(dir, 'git.config') await writeFile(config, '') + const env = { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' } const check = (spec: string, timeoutMs = 3000, outputBytes = 4096) => { const task = checkGithubConnection(parseInstallSpec(spec), dir, { timeoutMs, outputBytes, signal: controller.signal, - env: { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }, + env, }) tasks.push(task) return task } - return { dir, config, controller, check } + return { dir, config, controller, check, env } } async function proxy(config: string, fail: boolean) { @@ -47,7 +49,7 @@ async function proxy(config: string, fail: boolean) { }) onTestFinished(async () => { for (const socket of sockets) socket.destroy() - await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) }) server.listen(0, '127.0.0.1') await once(server, 'listening') @@ -118,7 +120,7 @@ it('cancels a connected Git probe and closes its transport before the deadline', it.each([undefined, 'Git could not start'])('retains a diagnostic when Git writes no stderr (%s)', async (shortMessage) => { const { check } = await fixture() const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, shortMessage } as Awaited>) - onTestFinished(() => run.mockRestore()) + onTestFinished(() => { run.mockRestore() }) const result = await check('github:acme/plugin') expect(result).toMatchObject({ exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed' }) expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) @@ -131,8 +133,51 @@ it('bounds returned diagnostics while retaining Git stderr in the operation log' appendFileSync(options.stderr.file, '0123456789') return Promise.resolve({ failed: true, exitCode: 128 }) as ReturnType }) - onTestFinished(() => run.mockRestore()) + onTestFinished(() => { run.mockRestore() }) const result = await check('github:acme/plugin', 3000, 4) expect(result).toMatchObject({ output: '6789', truncated: true }) expect(await readFile(result!.logPath, 'utf8')).toBe('0123456789') }) + +it('reports a missing Git executable without classifying it as a network failure', async () => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, code: 'ENOENT', shortMessage: 'spawn git ENOENT' } as Awaited>) + onTestFinished(() => { run.mockRestore() }) + expect(await check('github:acme/plugin')).toMatchObject({ exitCode: 127, kind: 'unknown', output: 'spawn git ENOENT' }) +}) + +it('does not invoke configured credential helpers or askpass for an authentication challenge', async () => { + const { check, dir, config, env } = await fixture() + const server = createHttpServer((_request, response) => { + response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }) + response.end() + }) + onTestFinished(async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port') + const marker = join(dir, 'prompted') + const helper = join(dir, 'credential-helper.cjs') + await writeFile(helper, `require('node:fs').appendFileSync(${JSON.stringify(marker)}, process.argv.slice(2).join(' ') + '\\n')`) + const helperCommand = `"${process.execPath.replaceAll('\\', '/')}" "${helper.replaceAll('\\', '/')}"` + const askpass = join(dir, 'askpass.sh') + await writeFile(askpass, `#!/bin/sh\nexec ${helperCommand} "$@"\n`, { mode: 0o700 }) + await writeFile(config, `[url "http://127.0.0.1:${String(address.port)}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`) + await subprocess.execa('git', ['config', '--file', config, 'credential.helper', `!${helperCommand}`]) + await subprocess.execa('git', ['config', '--file', config, 'core.askPass', askpass]) + Object.assign(env, { GIT_ASKPASS: askpass, SSH_ASKPASS: askpass }) + // The same Git configuration really prompts without the probe's noninteractive overrides. + await subprocess.execa('git', ['ls-remote', '--', 'https://github.com/acme/private.git', 'HEAD'], { + cwd: dir, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, reject: false, + }) + const prompted = await readFile(marker, 'utf8') + expect(prompted).toContain('get') + expect(prompted).toContain('Username') + await rm(marker) + expect(await check('github:acme/private.git')).toMatchObject({ exitCode: 128, kind: 'unknown' }) + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/packages/boot/plugin-manager/tests/install-spec.spec.ts b/packages/boot/plugin-manager/tests/install-spec.spec.ts index c45d461e67..10a4a76398 100644 --- a/packages/boot/plugin-manager/tests/install-spec.spec.ts +++ b/packages/boot/plugin-manager/tests/install-spec.spec.ts @@ -56,6 +56,10 @@ describe('parseInstallSpec', () => { }) describe('classifyInstallFailure', () => { + it.each([401, 403, 404])('leaves Git HTTP %s responses out of connection recovery', (status) => { + expect(classifyInstallFailure({ log: `fatal: unable to access 'https://github.com/acme/private.git/': The requested URL returned error: ${String(status)}` })).toBe('unknown') + }) + it('names the run\'s end before reading its output, then the most specific code in the output', () => { expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout') expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing') diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index c08ff6eec1..5c372a07a8 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1,6 +1,9 @@ /** Persistent manager behavior through a real profile Include and Loader. */ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { realpath } from 'node:fs/promises' +import { once } from 'node:events' +import { createServer } from 'node:http' +import type { Socket } from 'node:net' import { join } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -80,7 +83,7 @@ it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attr const failure = { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind } connection.mockResolvedValue(failure) const pnpm = vi.spyOn(operations, 'runProfilePnpm') - onTestFinished(() => pnpm.mockRestore()) + onTestFinished(() => { pnpm.mockRestore() }) expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({ application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', packageResult: failure, }) @@ -89,12 +92,17 @@ it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attr expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) }) -it('keeps non-network GitHub errors out of mirror recovery and forwards profile Git environment and deadline', async () => { +it('leaves non-network GitHub errors to pnpm and forwards profile Git environment and deadline', async () => { const env = { GIT_CONFIG_GLOBAL: '/application/git.config' } const { manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env }) connection.mockResolvedValue({ exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown' }) + const failure = { exitCode: 1, output: 'pnpm owns authentication', truncated: false, logPath: 'pnpm.log', kind: 'unknown' as const } + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue(failure) + onTestFinished(() => { pnpm.mockRestore() }) const result = await manager.installBundle('github:acme/private-plugin') expect(result.application).toBe('failed') + expect(result.packageResult).toEqual(failure) + expect(pnpm).toHaveBeenCalledOnce() expect(result.failedAt).toBeUndefined() expect(connection).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ timeoutMs: 8000, env })) }) @@ -104,11 +112,11 @@ it('cancels an active GitHub check before starting pnpm', async () => { const entered = Promise.withResolvers() connection.mockImplementation(async (_spec, _dir, options) => { entered.resolve(undefined) - await new Promise(resolve => options.signal.addEventListener('abort', () => resolve(), { once: true })) + await new Promise((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) }) return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } }) const pnpm = vi.spyOn(operations, 'runProfilePnpm') - onTestFinished(() => pnpm.mockRestore()) + onTestFinished(() => { pnpm.mockRestore() }) const requestId = '824103ec-bc45-489d-bb85-5b4fe0aefc78' as PluginInstallRequestId const installing = manager.installBundle('github:acme/dsh-plugin', { requestId }) await entered.promise @@ -144,46 +152,76 @@ it('disposal waits for an active GitHub check to stop', async () => { const released = Promise.withResolvers() connection.mockImplementation(async (_spec, _dir, options) => { entered.resolve(undefined) - await new Promise(resolve => options.signal.addEventListener('abort', () => resolve(), { once: true })) + await new Promise((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) }) released.resolve(undefined) return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => { pnpm.mockRestore() }) const installing = manager.installBundle('github:acme/addon') await entered.promise await ctx.fiber.dispose() await released.promise - expect(await installing).toMatchObject({ application: 'failed', changed: false }) + expect(await installing).toMatchObject({ application: 'cancelled', changed: false }) + expect(pnpm).not.toHaveBeenCalled() }) -it('installs through real Git and pnpm after a successful GitHub connection check', async () => { - const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url)) - const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' } - const { manager, dir, connection } = await fixture('startup', false, undefined, {}, { - command: process.execPath, args: ['--expose-internals', pnpm], env, - }) - connection.mockRestore() - const repository = join(dir, 'repository') - mkdirSync(repository) - env.GIT_CONFIG_GLOBAL = join(dir, 'git.config') - writeFileSync(env.GIT_CONFIG_GLOBAL, '') - const git = (args: string[]) => execa('git', args, { cwd: repository, env }) - await git(['init', '--initial-branch=main']) - const name = '@test/github-connected' - writeFileSync(join(repository, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) - writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n') - await git(['add', '.']) - await git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle']) - writeFileSync(env.GIT_CONFIG_GLOBAL, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ssh://git@github.com/acme/connected.git\n insteadOf = git@github.com:acme/connected.git\n`) - // SSH keeps pnpm on Git transport; Git's URL rewrite makes this success path entirely local. - const manifest = readProfileManifest('test', dir) - delete manifest.dependencies - writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) - writeFileSync(join(dir, '.npmrc'), `store-dir=${join(dir, 'store').replaceAll('\\', '/')}\n`) - const result = await manager.installBundle('git+ssh://git@github.com/acme/connected.git', { enabled: false }) - expect(result.error).toBeUndefined() - expect(result).toMatchObject({ changed: true, bundle: name, packageResult: { exitCode: 0 } }) - expect(readProfileManifest('test', dir).dependencies).toHaveProperty(name) -}) +it.each(['github:acme/connected', 'https://github.com/acme/connected.git', 'git+ssh://git@github.com/acme/connected.git'])( + 'installs %s through real Git and pnpm with private repository SSH fallback', async (spec) => { + const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url)) + const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' } + const { manager, dir, connection } = await fixture('startup', false, undefined, {}, { + command: process.execPath, args: ['--expose-internals', pnpm], env, + }) + connection.mockRestore() + const repository = join(dir, 'repository') + mkdirSync(repository) + env.GIT_CONFIG_GLOBAL = join(dir, 'git.config') + writeFileSync(env.GIT_CONFIG_GLOBAL, '') + const git = (args: string[]) => execa('git', args, { cwd: repository, env }) + await git(['init', '--initial-branch=main']) + const name = '@test/github-connected' + writeFileSync(join(repository, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n') + await git(['add', '.']) + await git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle']) + const requests: string[] = [] + const sockets = new Set() + const proxy = createServer((_request, response) => { + response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }) + response.end() + }) + proxy.on('connection', (socket) => { + sockets.add(socket) + socket.once('close', () => { sockets.delete(socket) }) + }) + proxy.on('connect', (request, socket) => { + requests.push(request.url ?? '') + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) }) + }) + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + const address = proxy.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + const proxyUrl = `http://127.0.0.1:${String(address.port)}` + writeFileSync(env.GIT_CONFIG_GLOBAL, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ssh://git@github.com/acme/connected.git\n insteadOf = git@github.com:acme/connected.git\n[url "${proxyUrl}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`) + // pnpm probes HTTPS with Node before falling back to SSH, even for an SSH spec. + // Route that HTTP request to our proxy too; Git's URL rewrite alone cannot isolate it. + const manifest = readProfileManifest('test', dir) + delete manifest.dependencies + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + writeFileSync(join(dir, '.npmrc'), `store-dir=${join(dir, 'store').replaceAll('\\', '/')}\nhttps-proxy=${proxyUrl}\nproxy=${proxyUrl}\nnoproxy=\n`) + const result = await manager.installBundle(spec, { enabled: false }) + expect(result.error).toBeUndefined() + expect(result).toMatchObject({ changed: true, bundle: name, packageResult: { exitCode: 0 } }) + expect(readProfileManifest('test', dir).dependencies).toHaveProperty(name) + expect(requests).toContain('github.com:443') + }, +) it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])( 'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f503e4d186..97b0a8b5fd 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1629,7 +1629,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise', - description: 'Install a package using the same pnpm implementation as dsh plugin. GitHub repositories must pass a Git connection check within githubConnectionTimeoutMs before pnpm starts. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.', + description: 'Install a package using the same pnpm implementation as dsh plugin. GitHub repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.', parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.' }], returns: 'Package-manager diagnostics, the registries asked, and the observed activation outcome.', }, From 2aa3a469db845891eb2a223c8ba33d28b0fd1a61 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 23 Sep 2026 14:08:25 +0800 Subject: [PATCH 14/25] fix(agent-team): consume shared projections without panel loads --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 4 +- .../feature/2026-08-05-agent-teams.zh.md | 4 +- apps/web/tests/agent-team-panel.e2e.ts | 6 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 20 +++ docs/subsystems/session-projection.zh.md | 20 +++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../api/session-controller/src/control.ts | 3 + .../tests/control-queue.host.spec.ts | 51 +++++++ .../experimental/agent-team/README.i18n.yaml | 4 +- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 12 +- .../client-ui-agent-team/README.zh.md | 12 +- .../src/client/TeamAction.tsx | 67 ++------- .../src/client/locales.ts | 2 - .../client-ui-agent-team/src/client/mount.ts | 11 +- .../tests/browser-plugin.client.spec.ts | 19 +-- .../tests/team-action.client.spec.tsx | 128 ++++-------------- .../extensions/tool-cordis/src/api-catalog.ts | 8 ++ .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 + .../session/session-projection/README.zh.md | 2 + .../session/session-projection/src/index.ts | 13 +- scripts/gen-cordis-catalog.ts | 1 + 32 files changed, 213 insertions(+), 210 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 5a98fcfa4d..b17a826f3a 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 073b0ac745e5bb171987ff11b4bfba0a50a8087a -2026-08-05-agent-teams.zh.md: 0fbb477429d10fbfbcbfb5437e977f597f3ce4c2 +2026-08-05-agent-teams.md: 6f271b18fbf7498dd60098ca4f1ef610a398700a +2026-08-05-agent-teams.zh.md: 6e9a36b07167e661382b129079b6a98d88c0aa53 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 073b0ac745..6f271b18fb 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -56,9 +56,9 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Web projection -The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Current-session values use the standard `useProjection` hook; a teammate conversation reads its Lead through the shared cross-Session store. +The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data, and changes to registered wire keys replace control baselines for connected clients. -The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; opening the panel requests other active members' baselines without opening their conversations, and no model is shown before a durable selection or request exists. +The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it. Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 0fbb477429..6e9a36b071 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -56,9 +56,9 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Web projection -Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。当前会话的值使用标准 `useProjection` 钩子;teammate 会话通过共享的跨 Session store 读取其 Lead。 +Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据,已注册传输 key 的变化会为已连接客户端替换控制基线。 -传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;打开 panel 时请求其他 active 成员的基线,无需打开它们的会话,在持久选择或请求出现前不显示模型。 +传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 3ffb5dedff..cf3fe29662 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { readFileSync } from 'node:fs' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import type {} from '@deepseek-ai/dsh-experimental-agent-team' @@ -82,10 +82,13 @@ describe('web e2e: Agent Teams panel', () => { it('displays agent-owned task changes through a read-only board without a refresh action', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) + const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') + onTestFinished(() => { projectionReads.mockRestore() }) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) await action.getByText('No shared tasks yet').waitFor() await action.getByText('lead').waitFor() + expect(projectionReads).not.toHaveBeenCalled() expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0) @@ -107,6 +110,7 @@ describe('web e2e: Agent Teams panel', () => { const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) + expect(projectionReads).not.toHaveBeenCalled() expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await action.getByRole('button', { name: 'Close' }).click() diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a6e1582653..295ce3768e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce -event-producer-consumer.zh.md: 73438c3f19ef6850674a27bf8f490fe4cf52b420 +event-producer-consumer.md: 3b259e4c32d5de56e6f97b214b5fd50ca3af8e6f +event-producer-consumer.zh.md: 3f2d1527919b819aec8d61a8e932876196caa673 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1d844013f1..3b259e4c32 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,6 +55,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 73438c3f19..3f2d152791 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -57,6 +57,7 @@ | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index cc738c4fcb..699f3162a0 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 8580b94c4d7dd36f942ff10d77e13d2dc70b7e8b -session-projection.zh.md: b98d348a36c51aab973bc3397ddbf744860245a4 +session-projection.md: 4cc68aab0e94d155b5f6736f61fdc2394e7bf622 +session-projection.zh.md: fd6e0428456a4893bcf15e7899baec4ea01f8295 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8580b94c4d..4cc68aab0e 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -352,5 +352,25 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md) +Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) + + + +### `session-projection/*` events + + + +#### `session-projection/definitions-changed` — emit + +The registered client-visible keys changed; carriers must replace their baselines. + +```ts cordis-catalog +/** + * The registered client-visible keys changed; carriers must replace their baselines. + * @mode emit + */ +'session-projection/definitions-changed'(): void +``` + Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b98d348a36..fd6e042845 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -352,5 +352,25 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md) +Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) + + + +### `session-projection/*` events + + + +#### `session-projection/definitions-changed` — emit + +The registered client-visible keys changed; carriers must replace their baselines. + +```ts cordis-catalog +/** + * The registered client-visible keys changed; carriers must replace their baselines. + * @mode emit + */ +'session-projection/definitions-changed'(): void +``` + Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index c283462c19..3a1d8a748c 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752 -README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272 +README.md: 08c68da01ab88e500970186cf799b33f9926289b +README.zh.md: a9f5878dc8bcf7134dd0d658ec477fe21ecbcf1e diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index fa2a251e94..08c68da01a 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -45,6 +45,8 @@ Accepted prompts and observed running keep their Client display conversion acros The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The control stream also sends a complete baseline when the registered client-visible projection keys change. Already-connected browsers receive late capability registration and removal without issuing a projection read. + The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index d1a73ed584..a9f5878dc8 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -45,6 +45,8 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +客户端可见投影 key 的注册集合变化时,控制流也发送完整基线。已连接浏览器无需发起投影读取,即可收到能力的晚注册与移除。 + Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 8d27396d29..48132e1e22 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -19,6 +19,9 @@ export class SessionControlController { /** @param ctx - Host context carrying live Agent and projection services. */ constructor(private readonly ctx: Context) { + ctx.on('session-projection/definitions-changed', () => { + if (this.streams.size > 0) this.broadcast({ type: 'baseline', value: this.baseline() }) + }) ctx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ type: 'projection', diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index ad24ad94fb..60c0a19dcb 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -1,3 +1,4 @@ +import { z } from 'zod' import { Context } from '@deepseek-ai/cordis' import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -17,6 +18,15 @@ declare module '@deepseek-ai/dsh-llm' { } } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + 'test/late-capability': number + } + interface SessionProjectionStateMap { + 'test/late-capability': number + } +} + const ownedContexts = new Set() afterEach(async () => { await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) @@ -56,6 +66,47 @@ describe('Session control Inbox projection', () => { } } + it('updates connected clients when a wire capability is registered, shared, and removed', async () => { + const { ctx, control, agent } = await harness() + const empty = ctx.sessions.create(SessionId('empty-late-capability')) + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + try { + await iterator.next() + const unit = { + key: 'test/late-capability' as const, + stateSchema: z.number(), + init: () => 7, + apply: (state: number) => state, + wire: { viewSchema: z.number(), view: (state: number) => state }, + stateVersion: 1, + } + const removeFirst = ctx.sessionProjections.register(unit) + const removeSecond = ctx.sessionProjections.register(unit) + removeFirst() + await expect(iterator.next()).resolves.toMatchObject({ value: { + type: 'baseline', value: { projections: { + [agent.session.id]: { values: { 'test/late-capability': 7 } }, + [empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } }, + } }, + } }) + removeSecond() + const removed = await iterator.next() + if (removed.done || removed.value.type !== 'baseline') throw new Error('missing removal baseline') + expect(removed.value.value.projections[agent.session.id]?.values).not.toHaveProperty('test/late-capability') + ctx.sessionProjections.register(unit) + await expect(iterator.next()).resolves.toMatchObject({ value: { + type: 'baseline', value: { projections: { + [agent.session.id]: { values: { 'test/late-capability': 7 } }, + [empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } }, + } }, + } }) + } finally { + abort.abort() + await iterator.return?.() + } + }) + it('projects both pending lists in baselines and live replacement frames', async () => { const { control, inbox } = await harness() const queued = message('queued') diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 696a104fa5..e95fcf19b7 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 094e7a781ff01ebab1498b6ccd207814ae722dfb -README.zh.md: e6be6e5b500c4ca635eeb52f82a12e5e07faaaab +README.md: 7295ff0c15ba441ab07b786de1958acc218a2759 +README.zh.md: 07fe1a237a8c5dafa8e9640114d22aa76223f2e0 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 094e7a781f..7295ff0c15 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -176,7 +176,7 @@ Read these pages when the package-level contract is not enough. They move from t The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. -The [Web UI](../client-ui-agent-team/README.md) owns projection baseline loading and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. +The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index e6be6e5b50..07fe1a237a 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -176,7 +176,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch `agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 -[Web UI](../client-ui-agent-team/README.zh.md) 负责投影基线加载,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 +[Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index d661eeb8c1..4895ad308e 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 267c607f311500884e60d399a9e65ef0bb7fd868 -README.zh.md: 81d218379d3a5f370f7fbb6759b218cb3f92c47f +README.md: c17f358332d3c61d1f6fa4dc5f4e4941d2dedec9 +README.zh.md: 66da22cd25a24aec1d9df72a2a7f85d126f379f3 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 267c607f31..c17f358332 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,7 +29,9 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the roster and task board of the Lead Session's `agentTeam` projection, so a task created by an agent or a teammate reaching `active` appears while the panel is open. Opening the panel requests the Lead's projection baseline once per connection, both in the Lead conversation and in a teammate conversation. A successful prior read is reused; a failed read shows a retry control alongside any last valid Team, and a reconnect reloads it. A successful read without the Team capability shows an unavailable notice. The panel also requests active members' projection baselines outside the current Session and Lead, including persisted Sessions that have not been opened, and loads new members when they become active. Roster rows show durable names and a status: `failed` and `provisioning` come from the durable member phase, and `running` or `inactive` comes from the member Session's live status. A model appears when the member Session's `modelSelection` projection records a durable selection or request. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The trigger shows the teammate count, and the panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward. + +Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation. ### Inspect the task board @@ -47,11 +49,11 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. The component reads the current Session's Team through `useProjection('agentTeam')` and its model through `useProjection('modelSelection')`. In a teammate conversation, `useSession` supplies the Lead identity and `useSessions` reads its Team value. Separate `useSessions` selectors read the Lead's baseline-read state and error in both conversation types. Each roster row selects its own model and running state; the panel subscribes to neither the complete projection collection nor the complete summary or status collection. Its only injected callbacks read a Session's projection baseline and open a teammate. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. `useSession` supplies the Lead identity; `useSessions` selects its Team and each member's model from the shared store. Each roster row selects its own running state. The only injected callback opens a teammate using the current and child Session ids. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | Locale, projection-read, navigation, and slot registrations | +| [`src/client/mount.ts`](src/client/mount.ts) | Locale, navigation, and slot registrations | | [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Projection-derived roster and task board with panel interaction state | | [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese panel copy | | [`src/index.ts`](src/index.ts) | Inert Host entry | @@ -83,10 +85,8 @@ No direct effect; the Team tools and ordinary conversation submission own any la -- **Cached capability absence** — if another UI loaded the Lead projection baseline before Agent Teams was enabled, opening this panel reuses that baseline; reconnect to load the newly enabled capability. -- **Member baseline failures** — the panel shows read errors only for the Lead. Failed reads for other active members retry when the panel reopens or receives a roster or task update; persistent failures can issue repeated requests while the Team is active. - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. -- **Model before the first request** — a member without a durable model selection or request shows no model. Inactive persisted members can supply their model through the explicit projection read; live activity still requires a running Host agent. +- **Model availability** — a model appears only when the shared store has a durable selection or request for that member. Missing cold-cache values stay absent until normal Session loading or a live update supplies them. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 81d218379d..66da22cd25 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,7 +29,9 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,panel 显示 Lead Session `agentTeam` 投影中的 roster 与任务板,因此 agent 创建的任务或进入 `active` 的 teammate 会在 panel 打开期间直接出现。无论在 Lead 会话还是 teammate 会话中,打开 panel 时都按每次连接一次的规则请求 Lead 的投影基线。之前读取成功则复用;读取失败会在最后有效 Team 旁显示重试控件,重连后会重新加载。读取成功但缺少 Team 能力时显示不可用提示。panel 还会请求当前 Session 与 Lead 之外 active 成员的投影基线,包括尚未打开的持久 Session,并在新成员进入 active 时加载它。Roster row 展示持久 name 与状态:`failed` 与 `provisioning` 来自持久成员 phase,`running` 或 `inactive` 来自成员 Session 的实时状态。当成员 Session 的 `modelSelection` 投影记录了持久选择或请求时显示 model。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +触发按钮显示 teammate 数量,面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 + +Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。 ### 查看任务板 @@ -47,11 +49,11 @@ kind: "package-reference" Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。组件通过 `useProjection('agentTeam')` 读取当前 Session 的 Team,通过 `useProjection('modelSelection')` 读取其 model。在 teammate 会话中,`useSession` 提供 Lead 身份,`useSessions` 读取其 Team 值。两类会话均通过独立的 `useSessions` selector 读取 Lead 的基线读取状态和错误。每个 roster row 只选择自己的 model 和运行状态;panel 不订阅完整投影集合,也不订阅完整摘要或状态集合。它仅有的注入回调是读取 Session 的投影基线与打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。`useSession` 提供 Lead 身份;`useSessions` 从共享 store 选择其 Team 及每个成员的模型。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与子 Session 的 id 打开 teammate。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | locale、投影读取、导航与 slot registration | +| [`src/client/mount.ts`](src/client/mount.ts) | locale、导航与 slot registration | | [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | 由投影派生的 roster 与任务板及面板交互状态 | | [`src/client/locales.ts`](src/client/locales.ts) | 中英文 panel 文案 | | [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry | @@ -83,10 +85,8 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati -- **能力缺失的缓存** — 如果其他 UI 在启用 Agent Teams 前已读取 Lead 的投影基线,打开本面板会复用该基线;重新连接后才能加载新启用的能力。 -- **成员基线读取失败** — 面板只显示 Lead 的读取错误。其他 active 成员的读取失败会在重新打开面板或收到 roster、任务更新时重试;Team 活跃期间,持续失败可能反复发出请求。 - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 -- **首次请求前的模型** — 没有持久模型选择或请求的成员不显示模型。未运行的持久成员可通过显式投影读取提供模型;实时活动仍需要正在运行的 Host agent。 +- **模型可用性** — 仅当共享 store 包含该成员的持久选择或请求时才显示模型。冷缓存中缺少的值会保持缺失,直到正常 Session 加载或实时更新提供它们。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index cd1b765f0c..5a4dd88b65 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -7,7 +7,7 @@ import type { } from '@deepseek-ai/dsh-experimental-agent-team/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { - IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, + IconCloseOutlineRegular, IconUserOutlineRegular, StateDot, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -17,9 +17,8 @@ import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Read a Session's projection baseline once per connection, or retry a failed read. */ - loadProjections: (sessionId: SessionId) => void - openTeammate: (sessionId: SessionId, member: TeamMemberProjection) => void + /** Open a continuable child from the current conversation. */ + openTeammate: (sessionId: SessionId, childSessionId: SessionId) => void } /** Full props of the Team conversation-header action. */ @@ -29,10 +28,6 @@ export type TeamActionProps = /** Durable lifecycle overlaid with the member Session's live turn activity. */ type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' -function failureText(error: { readonly code: string; readonly message: string }): string { - return `${error.message} (${error.code})` -} - function statusKey(status: TeamTask['status']): TeamKey { switch (status) { case 'pending': return 'status.pending' @@ -72,26 +67,21 @@ function taskDotState(task: TeamTask): StateDotState { } type TeamMemberRowProps = Pick & { member: TeamMemberProjection onError: (message: string) => void } function TeamMemberRow({ - member, sessionId, useProjection, useSessions, useSessionStatus, openTeammate, onError, t, + member, sessionId, useSessions, useSessionStatus, openTeammate, onError, t, }: TeamMemberRowProps) { - const currentModel = useProjection('modelSelection', value => - member.id === sessionId ? value?.next?.model : undefined) - const otherModel = useSessions(state => member.id === sessionId - ? undefined - : state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) + const model = useSessions(state => state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) const running = useSessionStatus(state => state.get(member.id)?.running) const summaryRunning = useSessions(state => state.byId[member.id]?.running) const status: MemberStatus = member.phase === 'active' ? (running ?? summaryRunning) === true ? 'running' : 'inactive' : member.phase - const model = member.id === sessionId ? currentModel : otherModel return ( -
- )} - {team === undefined && readError === null && ( + {team === undefined && (
- - {t(ready ? 'unavailable' : 'loading')} + + {t(opening || listing ? 'loading' : 'unavailable')}
)} {team !== undefined && ( @@ -243,7 +205,6 @@ export function TeamAction({ key={member.id} member={member} sessionId={sessionId} - useProjection={useProjection} useSessions={useSessions} useSessionStatus={useSessionStatus} openTeammate={openTeammate} diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index c3365d4268..14d1eb0ed7 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -7,7 +7,6 @@ export const NS = 'agent-team' export const zh = { trigger: 'Agent Team', close: '关闭', - retry: '重试读取 Team', loading: '正在加载 Team…', unavailable: 'Team 暂不可用', failure: 'Team 持久记录无效:{message}', @@ -38,7 +37,6 @@ export type TeamKey = keyof typeof zh export const en = { trigger: 'Agent Team', close: 'Close', - retry: 'Retry reading Team', loading: 'Loading Team…', unavailable: 'Team is unavailable', failure: 'Invalid persisted Team record: {message}', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 1e2dfbdab3..4ca1b5e04c 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -1,6 +1,5 @@ /** Source-safe Agent Teams browser registration. */ -import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -18,7 +17,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required browser services for projection reads, navigation, slots, and localized copy. */ +/** Required browser services for navigation, slots, and localized copy. */ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale'] /** @@ -36,16 +35,12 @@ export function registerAgentTeamUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - loadProjections(sessionId: SessionId): void { - void sessions.refreshProjections(sessionId) - }, - openTeammate(sessionId: SessionId, member: TeamMemberProjection): void { - if (member.role !== 'teammate') return + openTeammate(sessionId: SessionId, childSessionId: SessionId): void { const parentSessionId = leadSessionId(sessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return ctx.uiWorkspace.openSession({ parentSessionId, - childSessionId: member.id, + childSessionId, mode: 'continuable', }) }, diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index d63394fd64..abc786646f 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -3,15 +3,12 @@ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import type { TeamMemberProjection } from '@deepseek-ai/dsh-experimental-agent-team/client' import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' const SESSION = 'team-session' as SessionId const CHILD = 'team-child' as SessionId -const LEAD: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } -const WORKER: TeamMemberProjection = { id: CHILD, name: 'worker', role: 'teammate', phase: 'active' } async function bench(options: { addressed?: boolean } = {}) { const ctx = new Context() @@ -57,12 +54,11 @@ async function bench(options: { addressed?: boolean } = {}) { .find(candidate => candidate.component === TeamAction) const actions = (): TeamActionInjected => { const injected = entry()!.inject!() - const { loadProjections, openTeammate } = injected - if (typeof loadProjections !== 'function' || typeof openTeammate !== 'function') { + const { openTeammate } = injected + if (typeof openTeammate !== 'function') { throw new Error('Team header action lacks its injected callbacks') } return { - loadProjections: loadProjections as TeamActionInjected['loadProjections'], openTeammate: openTeammate as TeamActionInjected['openTeammate'], } } @@ -88,10 +84,7 @@ describe('ui-team browser plugin', () => { const t = b.ctx.locale.bind('agent-team') expect(t('trigger')).toBe('Agent Team') - b.actions().loadProjections(SESSION) - expect(b.navigation).toEqual([['refresh', SESSION]]) - b.actions().openTeammate(SESSION, LEAD) - expect(b.navigation).toEqual([['refresh', SESSION]]) + expect(b.navigation).toEqual([]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() @@ -100,7 +93,7 @@ describe('ui-team browser plugin', () => { it('opens a continuable teammate address without touching the parent catalog', async () => { const b = await bench() - b.actions().openTeammate(SESSION, WORKER) + b.actions().openTeammate(SESSION, CHILD) expect(b.navigation).toEqual([ ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) @@ -108,7 +101,7 @@ describe('ui-team browser plugin', () => { it('routes teammate navigation from an addressed teammate conversation back through its Lead', async () => { const b = await bench({ addressed: true }) - b.actions().openTeammate(CHILD, WORKER) + b.actions().openTeammate(CHILD, CHILD) expect(b.navigation).toEqual([ ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) @@ -117,7 +110,7 @@ describe('ui-team browser plugin', () => { it('does not open a teammate from a conversation outside the main view', async () => { const b = await bench() b.select('other-session' as SessionId) - b.actions().openTeammate(SESSION, WORKER) + b.actions().openTeammate(SESSION, CHILD) expect(b.navigation).toEqual([]) }) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 2f41bc5a94..2bfdcb6683 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -10,7 +10,7 @@ import type { import type { SessionListState, SessionSnapshot, SessionSummary, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx' import { zh } from '../src/client/locales.ts' @@ -80,7 +80,7 @@ function bench(options: { awaitingFirstTurn: false, }) const useSessions = bindSnapshotSelector(sessions) - const injected: TeamActionInjected = { loadProjections: vi.fn(), openTeammate: vi.fn() } + const injected: TeamActionInjected = { openTeammate: vi.fn() } const props: TeamActionProps = { sessionId, useSession: bindSnapshotSelector(session), @@ -125,7 +125,6 @@ describe('TeamAction', () => { openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() setProjection(b.sessions, SESSION, { @@ -137,24 +136,6 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') }) - it('reads the current Team and model through the standard projection hook', () => { - const b = bench() - const projected = createSnapshotStore({ ...team, tasks: [{ ...task, subject: 'Scoped Team task' }] }) - const useTeam = bindSnapshotSelector(projected) - const useProjection = ((key: string, select?: (value: unknown) => unknown) => { - const current = useTeam(value => value) - const value = key === 'agentTeam' ? current : { next: { model: 'scoped-model' } } - return select === undefined ? value : select(value) - }) as UseProjection - render() - openPanel() - expect(screen.getByText('Scoped Team task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() - expect(screen.getByRole('button', { name: /lead.*scoped-model/u })).toBeTruthy() - act(() => { projected.set({ ...team, tasks: [{ ...task, subject: 'Updated scoped task' }] }) }) - expect(screen.getByText('Updated scoped task')).toBeTruthy() - }) - it('overlays live Session status and the durable model selection on roster rows', () => { const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]]) const b = bench({ @@ -192,59 +173,41 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) fireEvent.click(screen.getByRole('button', { name: /^worker/u })) - expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, worker) + expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, WORKER) }) - it('loads the parent Team and retries failed parent baseline reads', () => { - const b = bench({ sessionId: WORKER, parentSessionId: SESSION, projections: {} }) - render() - openPanel() - expect(screen.getByRole('status').textContent).toBe(zh.loading) - - setProjectionSnapshot(b.sessions, SESSION, { state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {} }) - expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') - expect(screen.queryByRole('status')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) - - setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) - expect(screen.getByRole('status').textContent).toBe(zh.loading) - - setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) - expect(screen.getByText(zh.empty)).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('loads projections for an already-open Lead and retries failed reads', () => { - const b = bench({ projections: {}, openState: 'open' }) - render() - expect(b.injected.loadProjections).not.toHaveBeenCalled() - openPanel() - expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(b.injected.loadProjections).toHaveBeenCalledExactlyOnceWith(SESSION) - - setProjectionSnapshot(b.sessions, SESSION, { - state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: {}, + it.each([false, true])('accepts shared baselines and late capability updates (teammate page: %s)', (addressed) => { + const b = bench({ + ...(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}), + projections: {}, openState: 'loading', }) - expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') - fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) - - setProjectionSnapshot(b.sessions, SESSION, { state: 'loading', error: null, values: {} }) + render() + openPanel() expect(screen.getByRole('status').textContent).toBe(zh.loading) + act(() => { b.session.set({ ...b.session.getSnapshot(), openState: 'open' }) }) + expect(screen.getByRole('status').textContent).toBe(zh.unavailable) + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: { agentTeam: team } }) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) + expect(screen.getByRole('status').textContent).toBe(zh.unavailable) setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) expect(screen.getByText(zh.empty)).toBeTruthy() - expect(screen.queryByRole('alert')).toBeNull() - expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + }) - fireEvent.click(screen.getByRole('button', { name: zh.close })) - expect(b.injected.loadProjections).toHaveBeenCalledTimes(2) + it('waits for the shared Session list and accepts cached projections without an explicit read', () => { + const b = bench({ projections: {} }) + b.sessions.update((draft) => { draft.phase = 'pending' }) + render() openPanel() - expect(b.injected.loadProjections).toHaveBeenCalledTimes(3) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) + expect(screen.getByRole('status').textContent).toBe(zh.loading) + act(() => { b.sessions.set({ + ...b.sessions.getSnapshot(), phase: 'ready', + projectionsBySession: { [SESSION]: { state: 'idle', error: null, values: { agentTeam: team } } }, + }) }) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() }) it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { @@ -277,7 +240,6 @@ describe('TeamAction', () => { expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) expect(screen.getByText('Updated parent task')).toBeTruthy() - expect(b.injected.loadProjections).toHaveBeenCalledWith(SESSION) }) it('shows capability absence after a successful read instead of loading forever', () => { @@ -288,39 +250,6 @@ describe('TeamAction', () => { expect(screen.getByRole('status').textContent).toBe('Team 暂不可用') }) - it('keeps the last parent Team visible beside a retryable baseline failure', () => { - const b = bench({ sessionId: WORKER, parentSessionId: SESSION }) - render() - openPanel() - setProjectionSnapshot(b.sessions, SESSION, { - state: 'error', error: new RemoteError('gateway/internal', 'offline', {}), values: { agentTeam: team }, - }) - expect(screen.getByText('Implement runtime')).toBeTruthy() - expect(screen.getByRole('alert').textContent).toBe('offline (gateway/internal)') - fireEvent.click(screen.getByRole('button', { name: zh.retry })) - expect(b.injected.loadProjections).toHaveBeenLastCalledWith(SESSION) - }) - - it('loads active members models without opening their conversations', () => { - const nextMember = { ...worker, id: 'new-worker' as SessionId, name: 'new-worker', phase: 'provisioning' as const } - const failed = { ...worker, id: 'failed-worker' as SessionId, name: 'failed-worker', phase: 'failed' as const } - const b = bench() - render() - expect(b.injected.loadProjections).not.toHaveBeenCalled() - openPanel() - expect(b.injected.loadProjections).toHaveBeenCalledWith(WORKER) - setProjectionSnapshot(b.sessions, WORKER, { - state: 'ready', error: null, - values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'cold-worker-model' } } }, - }) - expect(screen.getByRole('button', { name: /worker.*cold-worker-model/u })).toBeTruthy() - setProjection(b.sessions, SESSION, { ...team, members: [...team.members, nextMember, failed] }) - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(nextMember.id) - expect(b.injected.loadProjections).not.toHaveBeenCalledWith(failed.id) - setProjection(b.sessions, SESSION, { ...team, members: [...team.members, { ...nextMember, phase: 'active' }] }) - expect(b.injected.loadProjections).toHaveBeenCalledWith(nextMember.id) - }) - it('surfaces a Team projection failure beside the last valid state', () => { const b = bench({ projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } }, @@ -396,7 +325,6 @@ describe('TeamAction', () => { openPanel() expect(screen.queryByRole('alert')).toBeNull() expect(screen.getByRole('status').textContent).toBe(zh.loading) - expect(next.injected.loadProjections).toHaveBeenCalledWith('next-lead') }) it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2245f9ef68..9510f83378 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3979,6 +3979,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.', parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }], }, + { + name: 'session-projection/definitions-changed', + mode: 'emit', + signature: '\'session-projection/definitions-changed\'(): void', + summary: 'The registered client-visible keys changed; carriers must replace their baselines.', + description: 'The registered client-visible keys changed; carriers must replace their baselines.', + parameters: [], + }, { name: 'session-telemetry/record', mode: 'waterfall', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 0a5ccf8611..586532b276 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: 2fa7ddc096e64e622c814f216a6c606de6ba1689 -README.zh.md: 565b331cd7e0f5190b6da4e54ed659d9da6d96fc +README.md: d8262b498ac1ddcb6443d3e9db35c24ed0634ce9 +README.zh.md: 6be8a50733457a123aaa4436da7dde8008410cea diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 2fa7ddc096..d8262b498a 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -57,6 +57,8 @@ const definition = { `register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. +The registry emits `session-projection/definitions-changed` after a client-visible key is first registered or its last registration is removed. Carriers replace their baselines so connected clients observe capability changes without waiting for another Session event. Duplicate registrations and host-only units do not trigger this notification. + ```text const dispose = ctx.sessionProjections.register(definition) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 565b331cd7..6be8a50733 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -57,6 +57,8 @@ const definition = { `register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 +客户端可见 key 首次注册或最后一次注册被移除后,注册表发出 `session-projection/definitions-changed`。传输层替换基线,使已连接客户端无需等待下一个 Session 事件即可观察能力变化。重复注册与仅 Host 使用的投影单元不触发此通知。 + ```text const dispose = ctx.sessionProjections.register(definition) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 6d28631ddc..d7e813425a 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -31,6 +31,13 @@ declare module '@deepseek-ai/cordis' { interface Context { sessionProjections: SessionProjectionRegistry } + interface Events { + /** + * The registered client-visible keys changed; carriers must replace their baselines. + * @mode emit + */ + 'session-projection/definitions-changed'(): void + } } import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' @@ -286,8 +293,12 @@ export class SessionProjectionRegistry extends Service { /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ if (live === undefined) return live.refs -= 1 - if (live.refs === 0) this.registrations.delete(key) + if (live.refs === 0) { + this.registrations.delete(key) + if (live.def.wire !== undefined) this.ctx.emit('session-projection/definitions-changed') + } } + if (existing === undefined && erased.wire !== undefined) this.ctx.emit('session-projection/definitions-changed') }.bind(this), 'sessionProjections.register()') return () => void dispose() } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 644f7073b2..0c9317d931 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -210,6 +210,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { * {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner. */ export const EVENT_SCOPE_PAGE: Record = { + 'session-projection': 'session-projection.md', 'app-boot': 'boot.md', hmr: 'boot.md', 'plugin-manager': 'boot.md', From 3bf6d738f13dc0e7860551caaadb340b20c68e7e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 23 Sep 2026 14:16:57 +0800 Subject: [PATCH 15/25] fix(agent-team): keep late activation on normal reconnect path --- .../feature/2026-08-05-agent-teams.i18n.yaml | 4 +- .../feature/2026-08-05-agent-teams.md | 2 +- .../feature/2026-08-05-agent-teams.zh.md | 2 +- apps/web/tests/plugin-manager.e2e.ts | 3 ++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 1 - docs/event-producer-consumer.zh.md | 1 - docs/subsystems/session-projection.i18n.yaml | 4 +- docs/subsystems/session-projection.md | 20 -------- docs/subsystems/session-projection.zh.md | 20 -------- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 - packages/api/session-controller/README.zh.md | 2 - .../api/session-controller/src/control.ts | 3 -- .../tests/control-queue.host.spec.ts | 51 ------------------- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 1 + .../client-ui-agent-team/README.zh.md | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 8 --- .../session-projection/README.i18n.yaml | 4 +- packages/session/session-projection/README.md | 2 - .../session/session-projection/README.zh.md | 2 - .../session/session-projection/src/index.ts | 13 +---- scripts/gen-cordis-catalog.ts | 1 - 24 files changed, 20 insertions(+), 139 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index b17a826f3a..4eb9df8cb0 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 6f271b18fbf7498dd60098ca4f1ef610a398700a -2026-08-05-agent-teams.zh.md: 6e9a36b07167e661382b129079b6a98d88c0aa53 +2026-08-05-agent-teams.md: 6333e511ba1e71823cfa55c27ba46b2665d1ef15 +2026-08-05-agent-teams.zh.md: 3fed1302146406f71d63c7ff2df8f031a91c9804 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 6f271b18fb..6333e511ba 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -56,7 +56,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may ## Web projection -The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data, and changes to registered wire keys replace control baselines for connected clients. +The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data. The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 6e9a36b071..3fed130214 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -56,7 +56,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 ## Web projection -Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据,已注册传输 key 的变化会为已连接客户端替换控制基线。 +Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。 传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 19a9c96e4d..05ff6010ab 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -280,6 +280,9 @@ describe('web e2e: plugin manager', () => { await action.waitFor({ timeout: 20_000 }) await action.getByRole('button', { name: /Agent Team/iu }).click() const teamPanel = teamPage.getByRole('dialog', { name: 'Agent Team', exact: true }) + await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor() + await teamPage.reload({ waitUntil: 'load' }) + await action.getByRole('button', { name: /Agent Team/iu }).click() await teamPanel.getByText('还没有共享任务').waitFor() await teamPanel.getByText('lead', { exact: true }).waitFor() const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 295ce3768e..a6e1582653 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 3b259e4c32d5de56e6f97b214b5fd50ca3af8e6f -event-producer-consumer.zh.md: 3f2d1527919b819aec8d61a8e932876196caa673 +event-producer-consumer.md: 1d844013f1230d6dbe535c3942279683f03738ce +event-producer-consumer.zh.md: 73438c3f19ef6850674a27bf8f490fe4cf52b420 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 3b259e4c32..1d844013f1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -55,7 +55,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3f2d152791..73438c3f19 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -57,7 +57,6 @@ | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | -| `session-projection/definitions-changed` | `emit` | [`packages/session/session-projection/src/index.ts:39`](../packages/session/session-projection/src/index.ts) | [`session-projection`](../packages/session/session-projection) (`emit`) | `session-controller` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 699f3162a0..cc738c4fcb 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md -session-projection.md: 4cc68aab0e94d155b5f6736f61fdc2394e7bf622 -session-projection.zh.md: fd6e0428456a4893bcf15e7899baec4ea01f8295 +session-projection.md: 8580b94c4d7dd36f942ff10d77e13d2dc70b7e8b +session-projection.zh.md: b98d348a36c51aab973bc3397ddbf744860245a4 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 4cc68aab0e..8580b94c4d 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -352,25 +352,5 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionLogOffset](session.md) -Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) - - - -### `session-projection/*` events - - - -#### `session-projection/definitions-changed` — emit - -The registered client-visible keys changed; carriers must replace their baselines. - -```ts cordis-catalog -/** - * The registered client-visible keys changed; carriers must replace their baselines. - * @mode emit - */ -'session-projection/definitions-changed'(): void -``` - Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index fd6e042845..b98d348a36 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -352,25 +352,5 @@ hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly Se Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionLogOffset](session.zh.md) -Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) - - - -### `session-projection/*` events - - - -#### `session-projection/definitions-changed` — emit - -The registered client-visible keys changed; carriers must replace their baselines. - -```ts cordis-catalog -/** - * The registered client-visible keys changed; carriers must replace their baselines. - * @mode emit - */ -'session-projection/definitions-changed'(): void -``` - Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3a1d8a748c..c283462c19 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 08c68da01ab88e500970186cf799b33f9926289b -README.zh.md: a9f5878dc8bcf7134dd0d658ec477fe21ecbcf1e +README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752 +README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 08c68da01a..fa2a251e94 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -45,8 +45,6 @@ Accepted prompts and observed running keep their Client display conversion acros The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. -The control stream also sends a complete baseline when the registered client-visible projection keys change. Already-connected browsers receive late capability registration and removal without issuing a projection read. - The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index a9f5878dc8..d1a73ed584 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -45,8 +45,6 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 -客户端可见投影 key 的注册集合变化时,控制流也发送完整基线。已连接浏览器无需发起投影读取,即可收到能力的晚注册与移除。 - Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 48132e1e22..8d27396d29 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -19,9 +19,6 @@ export class SessionControlController { /** @param ctx - Host context carrying live Agent and projection services. */ constructor(private readonly ctx: Context) { - ctx.on('session-projection/definitions-changed', () => { - if (this.streams.size > 0) this.broadcast({ type: 'baseline', value: this.baseline() }) - }) ctx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ type: 'projection', diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 60c0a19dcb..ad24ad94fb 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { Context } from '@deepseek-ai/cordis' import type { Agent, Inbox, InboxState } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -18,15 +17,6 @@ declare module '@deepseek-ai/dsh-llm' { } } -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionMap { - 'test/late-capability': number - } - interface SessionProjectionStateMap { - 'test/late-capability': number - } -} - const ownedContexts = new Set() afterEach(async () => { await Promise.all([...ownedContexts].map(ctx => ctx.fiber.dispose())) @@ -66,47 +56,6 @@ describe('Session control Inbox projection', () => { } } - it('updates connected clients when a wire capability is registered, shared, and removed', async () => { - const { ctx, control, agent } = await harness() - const empty = ctx.sessions.create(SessionId('empty-late-capability')) - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - try { - await iterator.next() - const unit = { - key: 'test/late-capability' as const, - stateSchema: z.number(), - init: () => 7, - apply: (state: number) => state, - wire: { viewSchema: z.number(), view: (state: number) => state }, - stateVersion: 1, - } - const removeFirst = ctx.sessionProjections.register(unit) - const removeSecond = ctx.sessionProjections.register(unit) - removeFirst() - await expect(iterator.next()).resolves.toMatchObject({ value: { - type: 'baseline', value: { projections: { - [agent.session.id]: { values: { 'test/late-capability': 7 } }, - [empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } }, - } }, - } }) - removeSecond() - const removed = await iterator.next() - if (removed.done || removed.value.type !== 'baseline') throw new Error('missing removal baseline') - expect(removed.value.value.projections[agent.session.id]?.values).not.toHaveProperty('test/late-capability') - ctx.sessionProjections.register(unit) - await expect(iterator.next()).resolves.toMatchObject({ value: { - type: 'baseline', value: { projections: { - [agent.session.id]: { values: { 'test/late-capability': 7 } }, - [empty.id]: { asOfSeq: -1, values: { 'test/late-capability': 7 } }, - } }, - } }) - } finally { - abort.abort() - await iterator.return?.() - } - }) - it('projects both pending lists in baselines and live replacement frames', async () => { const { control, inbox } = await harness() const queued = message('queued') diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 4895ad308e..c819bea01e 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: c17f358332d3c61d1f6fa4dc5f4e4941d2dedec9 -README.zh.md: 66da22cd25a24aec1d9df72a2a7f85d126f379f3 +README.md: 364388796442ed71e128ee97b08a39b41982b41b +README.zh.md: 71ad88a6f8f8ecb203af733cd66b5f41ed863b03 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index c17f358332..3643887964 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -86,6 +86,7 @@ No direct effect; the Team tools and ordinary conversation submission own any la - **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. +- **Late plugin activation** — after enabling Agent Teams in an already-open conversation, reload the page to receive its Team projection. - **Model availability** — a model appears only when the shared store has a durable selection or request for that member. Missing cold-cache values stay absent until normal Session loading or a live update supplies them. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 66da22cd25..71ad88a6f8 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -86,6 +86,7 @@ Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversati - **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 +- **晚启用插件** — 在已经打开的会话中启用 Agent Teams 后,需要刷新页面才能接收其 Team 投影。 - **模型可用性** — 仅当共享 store 包含该成员的持久选择或请求时才显示模型。冷缓存中缺少的值会保持缺失,直到正常 Session 加载或实时更新提供它们。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 9510f83378..2245f9ef68 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3979,14 +3979,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.', parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }], }, - { - name: 'session-projection/definitions-changed', - mode: 'emit', - signature: '\'session-projection/definitions-changed\'(): void', - summary: 'The registered client-visible keys changed; carriers must replace their baselines.', - description: 'The registered client-visible keys changed; carriers must replace their baselines.', - parameters: [], - }, { name: 'session-telemetry/record', mode: 'waterfall', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index 586532b276..0a5ccf8611 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection/README.md -README.md: d8262b498ac1ddcb6443d3e9db35c24ed0634ce9 -README.zh.md: 6be8a50733457a123aaa4436da7dde8008410cea +README.md: 2fa7ddc096e64e622c814f216a6c606de6ba1689 +README.zh.md: 565b331cd7e0f5190b6da4e54ed659d9da6d96fc diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index d8262b498a..2fa7ddc096 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -57,8 +57,6 @@ const definition = { `register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. -The registry emits `session-projection/definitions-changed` after a client-visible key is first registered or its last registration is removed. Carriers replace their baselines so connected clients observe capability changes without waiting for another Session event. Duplicate registrations and host-only units do not trigger this notification. - ```text const dispose = ctx.sessionProjections.register(definition) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 6be8a50733..565b331cd7 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -57,8 +57,6 @@ const definition = { `register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 -客户端可见 key 首次注册或最后一次注册被移除后,注册表发出 `session-projection/definitions-changed`。传输层替换基线,使已连接客户端无需等待下一个 Session 事件即可观察能力变化。重复注册与仅 Host 使用的投影单元不触发此通知。 - ```text const dispose = ctx.sessionProjections.register(definition) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index d7e813425a..6d28631ddc 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -31,13 +31,6 @@ declare module '@deepseek-ai/cordis' { interface Context { sessionProjections: SessionProjectionRegistry } - interface Events { - /** - * The registered client-visible keys changed; carriers must replace their baselines. - * @mode emit - */ - 'session-projection/definitions-changed'(): void - } } import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' @@ -293,12 +286,8 @@ export class SessionProjectionRegistry extends Service { /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ if (live === undefined) return live.refs -= 1 - if (live.refs === 0) { - this.registrations.delete(key) - if (live.def.wire !== undefined) this.ctx.emit('session-projection/definitions-changed') - } + if (live.refs === 0) this.registrations.delete(key) } - if (existing === undefined && erased.wire !== undefined) this.ctx.emit('session-projection/definitions-changed') }.bind(this), 'sessionProjections.register()') return () => void dispose() } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0c9317d931..644f7073b2 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -210,7 +210,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { * {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner. */ export const EVENT_SCOPE_PAGE: Record = { - 'session-projection': 'session-projection.md', 'app-boot': 'boot.md', hmr: 'boot.md', 'plugin-manager': 'boot.md', From f54606855316b15b7b0c3e55093b22e88209b78c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 23 Sep 2026 14:27:58 +0800 Subject: [PATCH 16/25] fix(agent-team): align package metadata with merged release --- packages/experimental/agent-team/package.json | 4 ++-- packages/experimental/client-ui-agent-team/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 7e7de96b32..680ea5c4c4 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,7 +44,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/schemastery": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 9d16cf2f5e..ef6657b6d7 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, From 5082e05f713ca5077340860c33f1013aca3949b8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 23 Sep 2026 14:43:23 +0800 Subject: [PATCH 17/25] fix(web): keep composer-stack tooltips above the input card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The goal strip and the queue dock ride directly above the input card, and both create a stacking context (`isolation: isolate`) so their frosted panel stays behind their own content. A tooltip bubble left inside that context resolves its z-index there, where the card — painted later in the same context — wins: the goal strip's bubble showed a two-pixel sliver and the queue dock's stayed entirely hidden. Portal those dock tooltips to the body, where the bubble's z-index resolves above the whole card. The ui-primitives `portal` contract now names the capping stacking context beside clipped containers, because neither container clips its bubble. The queue-actions aria goldens lose the tooltip line: the captured center column no longer contains the portaled bubble. The scenario still covers it by role and adds a hit test at the bubble's center and bottom edge, as does goal-bar. --- apps/web/tests/goal-bar.e2e.ts | 5 ++++- apps/web/tests/queue-actions.e2e.ts | 9 +++++++-- apps/web/tests/support.ts | 20 +++++++++++++++++++ .../src/client/queue/QueueDock.tsx | 10 +++++----- .../tests/queue-dock.client.spec.tsx | 16 +++++++++++++++ .../client/ui-goal/src/client/GoalBar.tsx | 12 +++++------ .../ui-goal/tests/goalbar.client.spec.tsx | 14 +++++++++++++ .../client/ui-primitives/README.i18n.yaml | 4 ++-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 5 +++-- .../web/queue-actions/editing.expected.md | 1 - snapshots/web/queue-actions/ui.expected.md | 1 - 13 files changed, 79 insertions(+), 22 deletions(-) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 787b69e959..a93c9d7c13 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -13,7 +13,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url)) const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md') @@ -58,6 +58,9 @@ describe('web e2e: goal bar clear convergence', () => { await pause.hover() const pauseTooltip = page.getByRole('tooltip', { name: 'Pause goal', exact: true }) await pauseTooltip.waitFor() + // The strip rides directly above the input card, which paints later; the + // bubble must escape the strip's stacking context instead of landing under it. + await expectTooltipOnTop(pauseTooltip) const tooltipGeometry = await page.evaluate(() => { const element = document.querySelector('[role="tooltip"]') if (element === null) return null diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index c5317cb3fb..9892033837 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -16,7 +16,7 @@ import { assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queue-actions', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) @@ -196,6 +196,9 @@ describe('web e2e: queue row actions', () => { await save.hover() const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true }) await saveTooltip.waitFor() + // The dock tucks under the input card, which paints later; the bubble must + // escape the panel's stacking context instead of landing behind it. + await expectTooltipOnTop(saveTooltip) const tooltipGeometry = await page.evaluate(() => { const element = document.querySelector('[role="tooltip"]') if (element === null) return null @@ -222,7 +225,9 @@ describe('web e2e: queue row actions', () => { const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true }) await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true) await remainingEdit.hover() - await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor() + const editTooltip = page.getByRole('tooltip', { name: 'Edit queued message', exact: true }) + await editTooltip.waitFor() + await expectTooltipOnTop(editTooltip) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9faa3d8f76..5873898473 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -225,6 +225,26 @@ export async function saveFailureShot(page: Page, name: string): Promise { } } +/** + * Assert a visible tooltip paints above the element a user would read through + * it: both probes must hit the bubble itself. The bubble ignores pointer events + * by design, so the measurement enables them for its own duration. + * @param tooltip - locator for the visible `[role="tooltip"]` bubble. + */ +export async function expectTooltipOnTop(tooltip: Locator): Promise { + expect(await tooltip.evaluate((element) => { + const bubble = element as HTMLElement + const rect = bubble.getBoundingClientRect() + const previous = bubble.style.pointerEvents + bubble.style.pointerEvents = 'auto' + const hitsBubble = (y: number): boolean => + document.elementFromPoint(rect.left + rect.width / 2, y) === bubble + const onTop = hitsBubble(rect.top + rect.height / 2) && hitsBubble(rect.bottom - 1) + bubble.style.pointerEvents = previous + return onTop + })).toBe(true) +} + /** * The conversation engine's Context key format, restated here rather than * imported: these specs live in the Host compiler aggregate, which must not diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index f8c7154ddd..2aa9056341 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -285,7 +285,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load {editing?.id === row.id ? ( <> - + - + - + - + - + )} {showResume && ( - + )} - + - + diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx index c321ccf5ff..d73443d67c 100644 --- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx @@ -151,6 +151,20 @@ describe('GoalBar', () => { expect(actions.onResume).toHaveBeenCalledTimes(1) }) + it('portals an action tooltip out of the strip, where the input card cannot cover it', () => { + vi.useFakeTimers() + try { + render() + fireEvent.mouseEnter(screen.getByRole('button', { name: '暂停目标' })) + act(() => { vi.advanceTimersByTime(500) }) + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toBe('暂停目标') + expect(tooltip.parentElement).toBe(document.body) + } finally { + vi.useRealTimers() + } + }) + it('a new goal identity drops the edit form (no stale draft over the new goal)', () => { const actions = makeActions() const { rerender } = render() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2bcf9054e3..9d9597eea7 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 332845471056e718d49d79a077b76a02c79bafe2 -README.zh.md: 598b561e79e4a935f7f033002e68b91c3a3d84de +README.md: 36ec3d016c1e39527fe71634157a8604e84aa3e7 +README.zh.md: 46e692b74d25e816632663490ba065cf188989fb diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 3328454710..36ec3d016c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Modal` | Centered dialog over a page mask. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | -| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers. | +| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 598b561e79..46e692b74d 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -52,7 +52,7 @@ kind: "package-library" | `Modal` | 页面遮罩之上的居中对话框。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | -| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪。 | +| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 | diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 93b06322ca..18ae1187cf 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,4 +1,4 @@ -/** Anchor-preserving tooltips with optional body portals for clipping containers. */ +/** Anchor-preserving tooltips; an optional body portal escapes clipping containers and stacking contexts that cap the bubble's z-index. */ import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' @@ -46,7 +46,8 @@ if (typeof window !== 'undefined') { * @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins * the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered * bubble would overlap); default 'center'. Ignored for side 'right'. - * @param props.portal - render the bubble under document.body to escape containing blocks and clipping ancestors. + * @param props.portal - render the bubble under document.body, so an ancestor's clipping or its + * stacking context (which resolves the bubble's z-index against its siblings) cannot hide it. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 0e02090d39..d375969b1e 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -21,7 +21,6 @@ - listitem: - textbox "Edit queued message": Edited queue item - button "Save queued message" - - tooltip "Save queued message" - button "Cancel editing" - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Add files or run commands" diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 6107949766..6dc03fa797 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -15,7 +15,6 @@ - listitem: - text: Edited queue item - button "Edit queued message" - - tooltip "Edit queued message" - button "Remove queued message" - button "Steer queued message" - textbox "Cmd/Ctrl+Enter steers all queued messages" From df7fa8945ceb59bc61febc9df4deacb4decea1a5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 23 Sep 2026 14:54:21 +0800 Subject: [PATCH 18/25] refactor(agent-team): narrow projection fields and task views --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 20 +++++++++---------- docs/persistence-catalog.zh.md | 20 +++++++++---------- docs/persistence-schema.json | 12 +++++------ docs/subsystems/agent-team.i18n.yaml | 4 ++-- docs/subsystems/agent-team.md | 3 --- docs/subsystems/agent-team.zh.md | 3 --- .../experimental/agent-team/README.i18n.yaml | 4 ++-- packages/experimental/agent-team/README.md | 2 +- packages/experimental/agent-team/README.zh.md | 2 +- .../experimental/agent-team/src/projection.ts | 8 +------- .../experimental/agent-team/src/task-board.ts | 13 ++++-------- .../experimental/agent-team/src/task-view.ts | 6 +++--- packages/experimental/agent-team/src/types.ts | 3 --- .../tests/projection-events.spec.ts | 4 ++-- .../tests/team-action.client.spec.tsx | 2 +- 19 files changed, 49 insertions(+), 69 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0fdd8ff430..0e07e102c8 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a396e48b0bc5017a71b4a8c6bb5d85d0d8af7644 -config-catalog.zh.md: 999173a8b36357d0a5f4ee91da2582e16ea279b5 +config-catalog.md: 17eff10706c7eda9fc2b81453d198b1ceb2dbec9 +config-catalog.zh.md: 5bad6a73466816d33dcac6ec8ab10ffdcedb117e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a396e48b0b..17eff10706 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -746,7 +746,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:155`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 999173a8b3..5bad6a7346 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -748,7 +748,7 @@ export interface Config { } ``` -来源: [`packages/experimental/agent-team/src/types.ts:155`](../packages/experimental/agent-team/src/types.ts) +来源: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 388b9937a3..32d3d4d57d 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 7f926ed76beb96e0d31aacf08842629d30c0817c -persistence-catalog.zh.md: ed39ff4bd76f23f56a738a71ae18a098f37f5f5e +persistence-catalog.md: 200e7156f65c4cba6fab6808a84326e85df69ddd +persistence-catalog.zh.md: fa48684d4456c6338b67d58bc051c3672d6ee2c9 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7f926ed76b..200e7156f6 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:131`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:140`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:239`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index ed39ff4bd7..fa48684d44 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:140`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:235`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:239`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:241`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:237`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index be9d2f1301..211f6c6fe6 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:241" + "packages/experimental/agent-team/src/types.ts:238" ] }, { @@ -31770,7 +31770,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSource" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:140" + "packages/experimental/agent-team/src/types.ts:137" ] }, { @@ -33739,7 +33739,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:131" + "packages/experimental/agent-team/src/types.ts:128" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:237" + "packages/experimental/agent-team/src/types.ts:234" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:235" + "packages/experimental/agent-team/src/types.ts:232" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:239" + "packages/experimental/agent-team/src/types.ts:236" ] }, { diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 4551177af7..03abbfe5b9 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md -agent-team.md: dc86afa2d682996eed0d11be42f91736e3675ae5 -agent-team.zh.md: 1ed2164ffbb7cf35d7e8d508e7cde2ca774e7496 +agent-team.md: 6cb503d130727b4c4694877f83c02f2e0983c8af +agent-team.zh.md: 083b9fafb6f45cd09ded84807da3c8602193b3d4 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index dc86afa2d6..6cb503d130 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -87,9 +87,6 @@ interface TeamMemberProjection { readonly role: 'lead' | 'teammate' /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ readonly phase: TeamMemberPhase - readonly description?: string - readonly provider?: string - readonly context?: 'fresh' | 'fork' readonly error?: string } ``` diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 1ed2164ffb..083b9fafb6 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -87,9 +87,6 @@ interface TeamMemberProjection { readonly role: 'lead' | 'teammate' /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ readonly phase: TeamMemberPhase - readonly description?: string - readonly provider?: string - readonly context?: 'fresh' | 'fork' readonly error?: string } ``` diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index e95fcf19b7..be20145568 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md -README.md: 7295ff0c15ba441ab07b786de1958acc218a2759 -README.zh.md: 07fe1a237a8c5dafa8e9640114d22aa76223f2e0 +README.md: 6d2aa6041039022e6508541fdb738181bbb54045 +README.zh.md: 9ea2977a976583aac08ca8bc53d61ff4812e1766 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 7295ff0c15..6d2aa60410 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -174,7 +174,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser projection -The `agentTeam` Session projection publishes the Lead Session's durable roster, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. +The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 07fe1a237a..9ea2977a97 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -174,7 +174,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch ### 浏览器投影 -`agentTeam` Session 投影发布 Lead Session 的持久 roster、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 +`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 [Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index 956b3d257f..bc9239d3d8 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -314,9 +314,6 @@ const teamMemberProjectionSchema = z.object({ name: z.string(), role: z.enum(['lead', 'teammate']), phase: z.enum(['provisioning', 'active', 'failed']), - description: z.string().optional(), - provider: z.string().optional(), - context: z.enum(['fresh', 'fork']).optional(), error: z.string().optional(), }).strict() as z.ZodType @@ -351,9 +348,6 @@ function buildTeamProjection(state: TeamProjectionState): TeamProjection { name: member.name, role: 'teammate', phase: member.phase, - description: member.description, - provider: member.provider, - context: member.context, ...member.error === undefined ? {} : { error: member.error }, }) } @@ -361,7 +355,7 @@ function buildTeamProjection(state: TeamProjectionState): TeamProjection { members, tasks: state.tasks .filter(task => task.status !== 'deleted') - .map(task => projectTaskView(rootId, state, task)), + .map(task => projectTaskView(state, task)), ...state.failure === undefined ? {} : { failure: state.failure }, } } diff --git a/packages/experimental/agent-team/src/task-board.ts b/packages/experimental/agent-team/src/task-board.ts index 88f2baeb4e..4b6132526a 100644 --- a/packages/experimental/agent-team/src/task-board.ts +++ b/packages/experimental/agent-team/src/task-board.ts @@ -64,7 +64,7 @@ export class TeamTaskBoard { } this.assertTaskGraph(state, task) await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task }) - return this.taskView(root, state, task) + return projectTaskView(state, task) }) } @@ -79,7 +79,7 @@ export class TeamTaskBoard { const state = this.journal.state(root) const task = state.tasks.find(candidate => candidate.id === id) if (task === undefined) throw new TeamError(`team task "${id}" not found`, 'TEAM_TASK_NOT_FOUND') - return this.taskView(root, state, task) + return projectTaskView(state, task) } /** @@ -92,7 +92,7 @@ export class TeamTaskBoard { const state = this.journal.state(root) return state.tasks .filter(task => task.status !== 'deleted') - .map(task => this.taskView(root, state, task)) + .map(task => projectTaskView(state, task)) } /** @@ -206,7 +206,7 @@ export class TeamTaskBoard { } this.assertTaskGraph(state, task) await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task }) - return this.taskView(root, state, task) + return projectTaskView(state, task) }) } @@ -252,9 +252,4 @@ export class TeamTaskBoard { const { ownerId: _ownerId, ...without } = task return without } - - /** View one task against the supplied state through the shared derivation. */ - private taskView(root: Agent, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { - return projectTaskView(root.id, state, task) - } } diff --git a/packages/experimental/agent-team/src/task-view.ts b/packages/experimental/agent-team/src/task-view.ts index e0a5fe7002..569ff38261 100644 --- a/packages/experimental/agent-team/src/task-view.ts +++ b/packages/experimental/agent-team/src/task-view.ts @@ -1,5 +1,6 @@ /** Pure task-view derivation shared by the task board and the client projection. */ +import { brandString } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session' import type { TeamState } from './projection.ts' import type { TeamTaskSnapshot, TeamTaskView } from './types.ts' @@ -29,15 +30,14 @@ export function taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { * A committing caller may pass its pre-append state because `task` supplies the * new value explicitly; owner names, blocker readiness, and other task scopes * do not change when that snapshot is appended. - * @param rootId - Team Lead Session identity named `lead` in owner names. * @param state - Team state supplying members and sibling tasks. * @param task - durable task snapshot to view. * @returns a detached task view. */ -export function projectTaskView(rootId: SessionId, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { +export function projectTaskView(state: TeamState, task: TeamTaskSnapshot): TeamTaskView { const ownerName = task.ownerId === undefined ? undefined - : task.ownerId === rootId + : task.ownerId === brandString(state.id) ? 'lead' : state.members.find(member => member.id === task.ownerId)?.name const warnings = new Set() diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 0b51530535..9921886f6f 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -103,9 +103,6 @@ export interface TeamMemberProjection { readonly role: 'lead' | 'teammate' /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ readonly phase: TeamMemberPhase - readonly description?: string - readonly provider?: string - readonly context?: 'fresh' | 'fork' readonly error?: string } diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 02a837a0f3..2266b41483 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -496,7 +496,7 @@ describe('Agent Teams projection events', () => { expect(view).toEqual({ members: [ { id: ROOT, name: 'lead', role: 'lead', phase: 'active' }, - { id: CHILD, name: 'worker-a', role: 'teammate', phase: 'active', description: 'worker', provider: 'spawn', context: 'fresh' }, + { id: CHILD, name: 'worker-a', role: 'teammate', phase: 'active' }, ], tasks: [ { @@ -520,7 +520,7 @@ describe('Agent Teams projection events', () => { const memberView = teamProjectionView(withMember) expect(memberView).not.toBe(view) expect(memberView.members.at(-1)).toEqual({ - id: 'child-b', name: 'worker-b', role: 'teammate', phase: 'provisioning', description: 'worker', provider: 'spawn', context: 'fresh', + id: 'child-b', name: 'worker-b', role: 'teammate', phase: 'provisioning', }) const failedMember = event('team/member', { diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 2bfdcb6683..3aca5c5cc8 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -35,7 +35,7 @@ const task: TeamTask = { } const lead: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } const worker: TeamMemberProjection = { - id: WORKER, name: 'worker', role: 'teammate', phase: 'active', description: 'worker', provider: 'spawn', context: 'fresh', + id: WORKER, name: 'worker', role: 'teammate', phase: 'active', } const team: TeamProjection = { members: [lead, worker], tasks: [task] } From a96d45af406cfdc66f196e11830863116692f949 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 23 Sep 2026 15:00:52 +0800 Subject: [PATCH 19/25] test(web): name the covering element in the tooltip hit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the tooltip layering fix. The helper probes `bubble.contains(hit)` instead of identity, so a bubble that grows child elements still passes, and each probe returns the hit element's tag and class, so a regression reports what covered the bubble instead of a bare `expected false to be true` — the pre-fix run now names `DIV.u82K-q_input` for both probes. The `portal` JSDoc says the ancestor stacking context confines the bubble's z-index to that context rather than resolving it against siblings. --- apps/web/tests/support.ts | 22 +++++++++++-------- packages/client/ui-primitives/src/Tooltip.tsx | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 5873898473..884f160418 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -227,22 +227,26 @@ export async function saveFailureShot(page: Page, name: string): Promise { /** * Assert a visible tooltip paints above the element a user would read through - * it: both probes must hit the bubble itself. The bubble ignores pointer events - * by design, so the measurement enables them for its own duration. + * it, at the bubble's center and bottom edge. The bubble ignores pointer events + * by design, so the measurement enables them for its own duration; each probe + * reports the bubble or the covering element, so a failure names its cover. * @param tooltip - locator for the visible `[role="tooltip"]` bubble. */ export async function expectTooltipOnTop(tooltip: Locator): Promise { - expect(await tooltip.evaluate((element) => { - const bubble = element as HTMLElement + const probes = await tooltip.evaluate((bubble) => { const rect = bubble.getBoundingClientRect() const previous = bubble.style.pointerEvents bubble.style.pointerEvents = 'auto' - const hitsBubble = (y: number): boolean => - document.elementFromPoint(rect.left + rect.width / 2, y) === bubble - const onTop = hitsBubble(rect.top + rect.height / 2) && hitsBubble(rect.bottom - 1) + const probe = (y: number): string => { + const hit = document.elementFromPoint(rect.left + rect.width / 2, y) + if (hit === null) return 'none' + return bubble.contains(hit) ? 'tooltip' : `${hit.tagName}.${hit.classList.value}`.slice(0, 120) + } + const probes = { center: probe(rect.top + rect.height / 2), bottom: probe(rect.bottom - 1) } bubble.style.pointerEvents = previous - return onTop - })).toBe(true) + return probes + }) + expect(probes).toEqual({ center: 'tooltip', bottom: 'tooltip' }) } /** diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 18ae1187cf..6ebd60dbd4 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -47,7 +47,7 @@ if (typeof window !== 'undefined') { * the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered * bubble would overlap); default 'center'. Ignored for side 'right'. * @param props.portal - render the bubble under document.body, so an ancestor's clipping or its - * stacking context (which resolves the bubble's z-index against its siblings) cannot hide it. + * stacking context (which confines the bubble's z-index to that context) cannot hide it. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). From 3c20f401d20e0bcae3fcaa63d66b5ce603689e3d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:07:13 -0700 Subject: [PATCH 20/25] fix(web): label beta features as experimental --- .../expected/agent-preset-authoring/section.expected.md | 2 +- apps/web/tests/expected/plugin-config/official.expected.md | 4 ++-- .../tests/expected/plugin-manager/live-enabled.expected.md | 4 ++-- apps/web/tests/expected/plugin-manager/manager.expected.md | 4 ++-- apps/web/tests/plugin-manager.e2e.ts | 2 +- packages/client/ui-agent-preset/src/client/locales.ts | 4 ++-- packages/client/ui-plugin-manager/README.i18n.yaml | 4 ++-- packages/client/ui-plugin-manager/README.md | 6 +++--- packages/client/ui-plugin-manager/README.zh.md | 6 +++--- packages/client/ui-plugin-manager/src/client/locales.ts | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 7bccb7e4c2..c1d3acc103 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -9,7 +9,7 @@ - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - - text: 新任务可选择模式 Beta + - text: 新任务可选择模式 实验性 - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 - switch "新任务可选择模式" [checked] - heading "内置" [level=3] diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index c1cac31c15..dd8775171f 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -7,11 +7,11 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - - text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index c9b222597d..f7d0ebbe50 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -7,11 +7,11 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - - text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index bb3c6cce17..3f33e93302 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -7,11 +7,11 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - - text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 19a9c96e4d..fa5b998aa0 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -128,7 +128,7 @@ describe('web e2e: plugin manager', () => { expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2) expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4) - expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2) + expect(await panel.getByText('实验性', { exact: true }).count()).toBe(2) expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false') // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 4137600704..90652929f9 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -71,7 +71,7 @@ export const en: Record = { creatorDraft: 'Let the agent help me create a preset', showPicker: 'Choose a mode for new tasks', - showPickerBeta: 'Beta', + showPickerBeta: 'Experimental', showPickerDescription: 'When enabled, each new task can choose a mode and the default is set here. When disabled, new tasks use the application default preset. Existing tasks are unaffected.', enablePickerToSetDefault: 'Turn on mode selection for new tasks to choose a default', @@ -112,7 +112,7 @@ export const zh: Record = { creatorDraft: '让 Agent 帮我创建预设模式', showPicker: '新任务可选择模式', - showPickerBeta: 'Beta', + showPickerBeta: '实验性', showPickerDescription: '开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。', enablePickerToSetDefault: '请先开启新任务模式选择,再设置默认值', enablePickerToCreate: '请先开启新任务模式选择,再启动创造模式', diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 0565efde3e..43c43b5b78 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: ceedcd69eabb90a59db99bcc244cff7bb1a98c84 -README.zh.md: f3c3d9e922dcd6bf8283287fb0e4aa9970fe390d +README.md: ae6bae12c8fead7ba4a4f3529a16b8a5225f9e3b +README.zh.md: 4ee4871f69dbcf4e14cdaa931c041631780a6e38 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index ceedcd69ea..ae6bae12c8 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -25,7 +25,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. +Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Experimental** for experimental features — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. @@ -65,7 +65,7 @@ The bundle's patch must declare the row under that id, and the registration exis ### Detail page extension points -A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, beta, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. +A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`. ```tsx ignore-check ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ @@ -101,7 +101,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form. -The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Beta marker. +The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker. `plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index f3c3d9e922..4ee4871f69 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 +在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于实验性功能的带**实验性**标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 @@ -65,7 +65,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### 详情页扩展点 -对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 +对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。 ```tsx ignore-check ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ @@ -101,7 +101,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()` 自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。 -页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示 Beta 标记。 +页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。 `plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。 diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 97e7309c77..bd27fb5041 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -17,7 +17,7 @@ export const zh = { bundlesTitle: '已安装', officialTitle: '官方', statusProblem: '异常', - statusBeta: 'Beta', + statusBeta: '实验性', reasonLabel: '原因', metadataError: '包元信息错误:{error}', versionTag: 'v{version}', @@ -206,7 +206,7 @@ export const en = { bundlesTitle: 'Installed', officialTitle: 'Official', statusProblem: 'Problem', - statusBeta: 'Beta', + statusBeta: 'Experimental', reasonLabel: 'Reason', metadataError: 'Package metadata error: {error}', versionTag: 'v{version}', From 86e3e0e448a625ee1a71f12fc732a1cea07b8025 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 21 Sep 2026 17:00:48 +0800 Subject: [PATCH 21/25] feat(web): refine the Team header panel interactions and adaptive header The Team panel opens on hover with the subagent-catalog grace timings, pins on click, polls the one-shot view RPC every 20s while open (replacing the toolbar refresh/close controls), and eagerly loads on mount so the trigger keeps a stable two-person icon. The lead card navigates from teammate conversations, the current conversation carries a Current chat tag (selected ring only among multiple members), task descriptions clamp to two lines behind an expand toggle, an empty board shows a one-line description, and a lone member narrows the panel to one column. The header title row becomes an inline-size container: the preset label hides first, then the Team trigger collapses to its icon and switches to click-only opening. The subagent catalog moves to the front of the actions band so lineage stays beside the title crumbs, and its pinned-menu behavior matches the Team panel. Tooltip gains a gap prop for tight anchors. --- apps/web/tests/agent-team-panel.e2e.ts | 19 +- .../src/client/AgentPresetLabel.module.css | 11 +- .../skeleton/ConversationRoot.module.css | 6 + packages/client/ui-primitives/src/Tooltip.tsx | 14 +- .../client/ui-primitives/src/icons/index.tsx | 23 +- .../ui-primitives/tests/icons.client.spec.tsx | 4 +- .../client/SubagentHeaderLineage.module.css | 30 +- .../src/client/SubagentHeaderLineage.tsx | 67 ++-- .../client/ui-subagent/src/client/index.ts | 7 +- .../client/ui-subagent/src/client/locales.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 4 +- .../tests/conversation-ui.client.spec.tsx | 73 +++- .../src/client/tool/toolviews/details-row.tsx | 5 + .../ui-tool/tests/details-row.client.spec.tsx | 6 + .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 8 +- .../client-ui-agent-team/README.zh.md | 8 +- .../src/client/TeamAction.module.css | 254 ++++++++++--- .../src/client/TeamAction.tsx | 338 ++++++++++++------ .../src/client/locales.ts | 20 +- .../client-ui-agent-team/src/client/mount.ts | 6 +- .../tests/browser-plugin.client.spec.ts | 8 +- .../tests/team-action.client.spec.tsx | 186 ++++++++-- 23 files changed, 810 insertions(+), 295 deletions(-) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index cf3fe29662..c846a9973a 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -86,7 +86,7 @@ describe('web e2e: Agent Teams panel', () => { onTestFinished(() => { projectionReads.mockRestore() }) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) - await action.getByText('No shared tasks yet').waitFor() + await action.getByText('No shared tasks yet, you can create in the conversation').waitFor() await action.getByText('lead').waitFor() expect(projectionReads).not.toHaveBeenCalled() @@ -113,7 +113,7 @@ describe('web e2e: Agent Teams panel', () => { expect(projectionReads).not.toHaveBeenCalled() expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await action.getByRole('button', { name: 'Close' }).click() + await page.keyboard.press('Escape') }, 60_000) it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { @@ -135,31 +135,24 @@ describe('web e2e: Agent Teams panel', () => { .every(([x, y]) => element.contains(document.elementFromPoint(x!, y!))) })).toBe(true) } - await panel.getByText('Shared tasks', { exact: true }).click() + await panel.getByRole('heading', { name: /Shared tasks/u }).click() expect(await panel.count()).toBe(1) await page.mouse.click(2, 2) await panel.waitFor({ state: 'detached' }) await page.setViewportSize(viewport) await trigger.focus() await page.keyboard.press('Enter') - await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() + await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor() expect(await panel.evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Tab') - expect(await panel.getByRole('button', { name: 'Close', exact: true }) - .evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Escape') await panel.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Enter') await panel.waitFor() - await page.keyboard.press('Tab') - await page.keyboard.press('Enter') - await panel.waitFor({ state: 'detached' }) - expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Enter') - await panel.waitFor() const outside = page.getByRole('button', { name: 'Settings', exact: true }) await outside.focus() + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') await panel.waitFor({ state: 'detached' }) expect(await outside.evaluate(element => element === document.activeElement)).toBe(true) } finally { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index 6d2cdd814b..dfcc2acc90 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -11,7 +11,7 @@ background: var(--dsw-alias-fill-tsp-secondary); font-size: 12px; line-height: 22px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -21,3 +21,12 @@ flex: none; opacity: 0.7; } + +/* First sacrifice when the title row (the enclosing inline-size container) + runs out of width: the preset is passive chrome, so it yields before any + interactive header action loses its label. */ +@container (max-width: 540px) { + .label { + display: none; + } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cabe3711ae..4bd7aa80b2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -63,6 +63,12 @@ .titleRow { grid-column: 2; + /* Anonymous inline-size container (CSS modules hash container-names per + module, so occupants match the nearest container): header occupants such + as the preset label and slot actions shed their text at row-width + breakpoints. The minmax(0, 1fr) column sizes the row, so inline-size + containment cannot collapse it. */ + container-type: inline-size; display: flex; align-items: center; gap: 0; diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 6ebd60dbd4..0f7dd5bae3 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -49,6 +49,8 @@ if (typeof window !== 'undefined') { * @param props.portal - render the bubble under document.body, so an ancestor's clipping or its * stacking context (which confines the bubble's z-index to that context) cannot hide it. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. + * @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8); + * ignored for side 'right'. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). * @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default @@ -59,7 +61,7 @@ if (typeof window !== 'undefined') { * anchor dismisses the bubble until the next trigger, and focus arriving after a pointer * interaction (a closing menu refocusing its trigger) never raises it. */ -export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { +export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -80,7 +82,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, ? 0 : side === 'right' ? pos.top + (pos.bottom - pos.top) / 2 - : side === 'top' ? pos.top - 8 : pos.bottom + 8 + : side === 'top' ? pos.top - gap : pos.bottom + gap const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -108,13 +110,13 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, const { inlineSize: width, blockSize: height } = size const offset = side === 'right' ? 0 : align === 'end' ? width : width / 2 const left = Math.max(edgeMargin, Math.min(pos.x - offset, window.innerWidth - edgeMargin - width)) - const fitsBelow = pos.bottom + 8 + height <= window.innerHeight - edgeMargin - const fitsAbove = pos.top - 8 - height >= edgeMargin + const fitsBelow = pos.bottom + gap + height <= window.innerHeight - edgeMargin + const fitsAbove = pos.top - gap - height >= edgeMargin if (placement === 'bottom' && !fitsBelow && fitsAbove) placement = 'top' else if (placement === 'top' && !fitsAbove && fitsBelow) placement = 'bottom' el.style.left = `${left + offset}px` el.style.top = `${placement === 'right' ? (pos.top + pos.bottom) / 2 - : placement === 'top' ? pos.top - 8 : pos.bottom + 8}px` + : placement === 'top' ? pos.top - gap : pos.bottom + gap}px` el.dataset.side = placement el.style.visibility = 'visible' } @@ -128,7 +130,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, observer.disconnect() window.removeEventListener('resize', fit) } - }, [align, pos, side, suppressed, visible]) + }, [align, gap, pos, side, suppressed, visible]) useEffect(() => { announce(visible) return () => { announce(false) } diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index fddac899db..1629d28a35 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -640,8 +640,8 @@ export const IconWarningTriangleOutlineMedium = (props: IconProps) => ( const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) @@ -655,6 +655,25 @@ export const IconUserOutlineMedium = (props: IconProps) => ( ) +const IconUsersOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( + +) + +/** Regular one-pixel IconUsersOutline artwork: a front person with a second person behind. */ +export const IconUsersOutlineRegular = (props: IconProps) => ( + +) + +/** Medium IconUsersOutline artwork with a 1.3px stroke. */ +export const IconUsersOutlineMedium = (props: IconProps) => ( + +) + const IconPaperPlaneOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => (
@@ -468,6 +469,8 @@ function CatalogDropdown({ const menuRef = useRef(null) const hoverOpenTimer = useRef | undefined>(undefined) const hoverCloseTimer = useRef | undefined>(undefined) + // A click-opened (pinned) menu ignores hover-out; only explicit dismissal closes it. + const pinnedRef = useRef(false) const currentEntry = currentSessionId === undefined ? undefined : catalog?.entries.find(entry => entry.id === currentSessionId) @@ -506,6 +509,7 @@ function CatalogDropdown({ setMenuPosition(catalogMenuPosition(trigger)) } else { + pinnedRef.current = false setOpen(false) setMenuPosition(undefined) setExpanded(new Set()) @@ -526,6 +530,7 @@ function CatalogDropdown({ const scheduleHoverClose = (): void => { cancelHoverOpen() cancelHoverClose() + if (pinnedRef.current) return hoverCloseTimer.current = setTimeout(() => { hoverCloseTimer.current = undefined changeOpen(false) @@ -602,6 +607,7 @@ function CatalogDropdown({ cancelHoverOpen() cancelHoverClose() if (!open) return + pinnedRef.current = false setOpen(false) setExpanded(new Set()) }, [visible, open]) @@ -658,7 +664,10 @@ function CatalogDropdown({ { count: runningCount > 0 ? runningCount : directCount }, )} onClick={openTitle === undefined - ? undefined + ? () => { + pinnedRef.current = true + if (!open) changeOpen(true) + } : () => { cancelHoverOpen() if (open) changeOpen(false) @@ -692,26 +701,26 @@ function CatalogDropdown({ ref={menuRef} className={css.menu} style={menuPosition} - role="tree" - aria-label={t('tree.aria')} onMouseEnter={cancelHoverClose} onMouseLeave={scheduleHoverClose} > - { changeOpen(false) }} - t={t} - /> +
+ { changeOpen(false) }} + t={t} + /> +
), document.body)} diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index fc70c93abf..e24490966d 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -84,9 +84,10 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // After the task list: the preset label leads, running work follows, - // and delegation navigation closes the band. - order: 30, + // Leads the band, directly after the title crumbs: subagent lineage is + // the title's own continuation, ahead of Team navigation (-20) and the + // preset label (-10). + order: -30, locale: NS, inject: catalogActions, }, SubagentCatalogAction), diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index c17a2f3cb1..5913d680ae 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -37,7 +37,7 @@ export const zh = { 'count.running.other': '{count} 个子代理,正在运行', 'switcher.aria': '切换子代理:{title}', 'tree.aria': '子代理会话', - 'open.sidebar': '在侧边栏打开 {label}', + 'open.sidebar': '在侧边栏打开', 'sidebar.chat': '聊天', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', @@ -79,7 +79,7 @@ export const en: Record = { 'count.running.other': '{count} subagents running', 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', - 'open.sidebar': 'Open {label} in sidebar', + 'open.sidebar': 'Open in sidebar', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 161ba8ec62..23aa61b015 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -134,10 +134,10 @@ describe('apply', () => { ]) // The root-session catalog seat registers in the actions band with the - // same business face, ordered after the task list. + // same business face, leading the band directly after the title crumbs. const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! - expect(actionEntry.options.order).toBe(30) + expect(actionEntry.options.order).toBe(-30) expect(actionEntry.inject).toBe(catalogEntry.inject) const composerEntry = ctx.slots.entries('conversation.composer') diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 381d7523fb..196229f58f 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -255,7 +255,8 @@ describe('SubagentHeaderLineage', () => { expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy() expect(screen.getByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeTruthy() - const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' }) + const sidebarButton = within(screen.getByRole('treeitem', { name: /worker/ })) + .getByRole('button', { name: '在侧边栏打开' }) fireEvent.keyDown(sidebarButton, { key: 'Enter' }) fireEvent.click(sidebarButton) expect(input.openChildAside).toHaveBeenCalledWith({ @@ -331,7 +332,7 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('tree')).toBeNull() }) - it('opens only on hover and preserves the portaled-menu crossing grace', async () => { + it('opens on hover and preserves the portaled-menu crossing grace', async () => { vi.useFakeTimers() const advance = async (duration: number): Promise => { await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) @@ -341,26 +342,23 @@ describe('SubagentHeaderLineage', () => { const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect') .mockReturnValue({ bottom: 40, left: 50 } as DOMRect) - fireEvent.click(trigger) - expect(screen.queryByRole('tree')).toBeNull() - fireEvent.mouseEnter(trigger.parentElement!) await advance(149) expect(screen.queryByRole('tree')).toBeNull() await advance(1) - const tree = screen.getByRole('tree') - expect(tree.style.top).toBe('45px') - expect(tree.style.left).toBe('50px') + const menu = screen.getByRole('tree').parentElement! + expect(menu.style.top).toBe('45px') + expect(menu.style.left).toBe('50px') triggerRect.mockReturnValue({ bottom: 60, left: 70 } as DOMRect) fireEvent.resize(window) - expect(tree.style.top).toBe('65px') - expect(tree.style.left).toBe('70px') + expect(menu.style.top).toBe('65px') + expect(menu.style.left).toBe('70px') fireEvent.mouseLeave(trigger.parentElement!) - fireEvent.mouseEnter(tree) + fireEvent.mouseEnter(menu) await advance(120) expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.mouseLeave(tree) + fireEvent.mouseLeave(menu) await advance(119) expect(screen.getByRole('tree')).toBeTruthy() await advance(1) @@ -372,25 +370,62 @@ describe('SubagentHeaderLineage', () => { await advance(120) }) + it('pins a click-opened catalog through hover-out until outside pointer or Escape', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const root = trigger.parentElement! + + fireEvent.click(trigger) + const menu = screen.getByRole('tree') + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('tree')).toBe(menu) + fireEvent.mouseEnter(menu.parentElement!) + fireEvent.mouseLeave(menu.parentElement!) + await advance(300) + expect(screen.getByRole('tree')).toBe(menu) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('tree')).toBeNull() + + fireEvent.mouseEnter(root) + await advance(150) + const hovered = screen.getByRole('tree') + fireEvent.click(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('tree')).toBe(hovered) + fireEvent.keyDown(hovered, { key: 'Escape' }) + expect(screen.queryByRole('tree')).toBeNull() + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.queryByRole('tree')).toBeNull() + }) + it('repositions an open catalog after viewport resize and document scroll', () => { const view = render() const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) const bounds = vi.spyOn(trigger, 'getBoundingClientRect') bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect) hoverCatalog(trigger) - const tree = screen.getByRole('tree') - expect(tree.style.top).toBe('25px') - expect(tree.style.left).toBe('30px') + const menu = screen.getByRole('tree').parentElement! + expect(menu.style.top).toBe('25px') + expect(menu.style.left).toBe('30px') bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect) act(() => { window.dispatchEvent(new Event('resize')) }) - expect(tree.style.top).toBe('75px') - expect(tree.style.left).toBe('80px') + expect(menu.style.top).toBe('75px') + expect(menu.style.left).toBe('80px') bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect) act(() => { document.dispatchEvent(new Event('scroll')) }) - expect(tree.style.top).toBe('95px') - expect(tree.style.left).toBe('100px') + expect(menu.style.top).toBe('95px') + expect(menu.style.left).toBe('100px') view.unmount() }) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx index a2bfcb447d..ce5344f6bf 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import { IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconClockOutlineRegular, IconCodeOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconSearchOutlineRegular, + IconUsersOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -59,7 +60,11 @@ const LSP_TITLE_KEYS = { hover: 'tool.title.hoverSymbol', } as const +/** Teammate-coordination tools presented with the two-person team icon. */ +const TEAMMATE_TOOLS = new Set(['spawn_teammate', 'list_agents', 'send_message', 'interrupt_agent', 'wait_agent']) + function detailIcon(toolName: string) { + if (TEAMMATE_TOOLS.has(toolName)) return if (toolName.startsWith('schedule_')) return if (toolName.endsWith('_goal')) return if (toolName.startsWith('cordis_')) return diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index acca5c7617..c1b0b54cea 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -90,6 +90,12 @@ describe('DetailsRow', () => { expect(inspect).toHaveBeenCalledOnce() }) + it('presents teammate-coordination tools under the two-person team icon', () => { + const inspect = vi.fn() + render([0]} />) + expect(screen.getByText('Wait for teammates')).toBeTruthy() + }) + it('registers the supported tool names through the scoped keyed slot', () => { const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined) const inject = vi.fn((_name: string, callback: () => Iterable<() => void>) => { diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index c819bea01e..ab4ea169b7 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/client-ui-agent-team/README.md -README.md: 364388796442ed71e128ee97b08a39b41982b41b -README.zh.md: 71ad88a6f8f8ecb203af733cd66b5f41ed863b03 +README.md: 85dedd82ef95bee134b8198afaf9820a5fb5ec71 +README.zh.md: ad91fcdcaeeb441e60340b0436ed76aa79f831f3 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 3643887964..85dedd82ef 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -29,15 +29,15 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -The trigger shows the teammate count, and the panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward. +The panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward. -Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation. +Roster rows show durable names and phases. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. The current conversation carries a Current chat tag and cannot be selected. Selecting the Lead from a teammate conversation opens the Lead Session directly. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation. ### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Descriptions longer than two lines have an expand toggle. Section headings show member and task counts; an empty board shows a short description, and a lone member with no tasks uses a single-column panel. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. ----- @@ -49,7 +49,7 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. `useSession` supplies the Lead identity; `useSessions` selects its Team and each member's model from the shared store. Each roster row selects its own running state. The only injected callback opens a teammate using the current and child Session ids. Switching conversations closes the panel and clears a navigation failure. +The panel renders outside the conversation container and stays within the viewport. Hovering the trigger opens the panel after 150ms; leaving both trigger and panel closes it after a 120ms grace period. Clicking the trigger pins the panel and moves focus into it. Outside clicks and Escape dismiss the panel; Escape returns focus to the trigger only when focus was inside the panel. In a narrow header, the trigger becomes an icon and opens only on click. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Each roster row selects its own running state. The only injected callback opens a roster Session using the current and target Session ids. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 71ad88a6f8..ad91fcdcae 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -29,15 +29,15 @@ kind: "package-reference" ### 检查并导航 roster -触发按钮显示 teammate 数量,面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 +面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 -Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。 +Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。当前会话带有“当前会话”标签且不可选择。在 teammate 会话中选择 Lead 会直接打开 Lead Session。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。 ### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。超过两行的描述提供展开按钮。分区标题显示成员与任务数量;空任务板显示简短描述,只有一名成员且无任务时采用单列面板。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 ----- @@ -49,7 +49,7 @@ Roster 行展示持久名称与阶段。实时 Session 状态提供运行活动 Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。`useSession` 提供 Lead 身份;`useSessions` 从共享 store 选择其 Team 及每个成员的模型。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与子 Session 的 id 打开 teammate。切换会话会关闭面板并清除导航失败。 +面板渲染在会话容器外,并保持在视口范围内。悬停触发按钮 150ms 后打开面板;指针离开触发按钮和面板后,经过 120ms 宽限关闭。点击触发按钮会固定面板并将焦点移入其中。点击外部或按 Escape 可关闭面板;仅当焦点原本位于面板内时,Escape 才将焦点返回触发按钮。页头较窄时触发按钮折叠为图标,只响应点击打开。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与目标 Session 的 id 打开 roster Session。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index 29d35ccaf0..0ce84652e7 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -2,90 +2,130 @@ position: relative; } -.trigger, -.iconButton { - border: 0; - border-radius: 6px; - background: transparent; - color: var(--dsw-alias-label-secondary); - cursor: pointer; -} - .trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 3px 7px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); font-size: 12px; + cursor: pointer; } .trigger:hover, -.iconButton:hover { - background: var(--dsw-alias-interactive-bg-hover); +.trigger:focus-visible { + color: var(--dsw-alias-label-primary); +} + +/* Second sacrifice after the preset label: past this title-row width the + trigger keeps only its icon. TeamAction reads the collapsed state off this + rule to switch from hover-open to click-only. */ +@container (max-width: 480px) { + .triggerLabel { + display: none; + } } .count { - min-width: 16px; - border-radius: 8px; - background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-caption); + font-size: 12px; + font-weight: 400; font-variant-numeric: tabular-nums; - text-align: center; + line-height: 16px; } .panel { --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + display: flex; + flex-direction: column; position: fixed; - z-index: 110; + z-index: 100; box-sizing: border-box; - width: min(560px, calc(100vw - 32px)); + width: min(500px, 100vw - 32px); max-height: min(680px, calc(100vh - 32px)); - padding: 10px; - overflow: auto; + /* Horizontal insets live on the toolbar and scroll body, not the panel, so + member-card hover shadows stay inside the body's clip box; the thumb hugs + the panel edge (2px) while the body's stable gutter and trailing padding + rebuild the 16px content inset. */ + padding: 8px 2px 0; + overflow: hidden; border: 0; border-radius: 12px; - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } -.toolbar, +/* The menu skin lives on a pseudo-element: backdrop-filter on the panel + itself would turn it into the containing block for fixed descendants, so + the member Tooltip bubbles would be re-anchored into the panel and clipped + by its hidden overflow. */ +.panel::before { + content: ''; + position: absolute; + z-index: -1; + inset: 0; + border-radius: 12px; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); +} + +/* A lone member with no shared tasks fits one card; the full panel width + would sit mostly empty, so the roster drops to one column. */ +.panelCompact { + width: min(320px, 100vw - 32px); +} + +.panelCompact .roster { + grid-template-columns: minmax(0, 1fr); +} + +.body { + --dsh-scrollbar-track-margin: 8px; + + flex: 1 1 auto; + min-height: 0; + padding: 0 9px 16px 14px; + overflow-y: auto; + scrollbar-gutter: stable; +} + .taskTitle { display: flex; align-items: center; gap: 8px; } -.toolbar { - min-height: 28px; -} - -.spacer { - flex: 1; -} - -.iconButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; +.taskTitle strong { + font-size: 13px; + font-weight: 500; } .panel h3 { - margin: 12px 0 6px; - color: var(--dsw-alias-label-secondary); - font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0 8px 4px; + color: var(--dsw-alias-label-primary); + font-size: 13px; font-weight: 500; } +/* Without a toolbar the first heading sits 16px under the panel edge: 8px + panel padding plus this trimmed margin. */ +.body section:first-of-type h3 { + margin-top: 8px; +} + .roster { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; + gap: 8px; } .member { @@ -93,10 +133,10 @@ align-items: flex-start; gap: 8px; min-width: 0; - padding: 8px; - border: 0.5px solid var(--dsw-alias-border-l1); + padding: 10px 12px; + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 8px; - background: transparent; + background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); text-align: left; cursor: pointer; @@ -106,12 +146,79 @@ cursor: default; } +/* The border echoes the Current tag's 'info' tint so the selected card and its + tag read as one state. The inset ring thickens the stroke without changing + the border width, so the card's box stays identical to its neighbors. */ +.memberCurrent { + border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); +} + +.member:not(:disabled):hover, +.member:not(:disabled):focus-visible { + border-color: var(--dsw-alias-border-l3); + box-shadow: var(--dsw-elevation-panel); +} + +/* Seat height 1lh tracks the member name's first text line, so any dot + diameter centers on that line instead of the row's top edge. */ +.memberDot { + display: inline-flex; + align-items: center; + flex: none; + height: 1lh; +} + +.inactiveIcon { + color: var(--dsw-alias-label-tertiary); +} + .memberText { display: flex; flex-direction: column; min-width: 0; } +.memberName { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.memberNameText, +.memberText small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Model details stay behind a hover/focus reveal to keep the resting card to + its name and activity. */ +.memberModel { + display: none; +} + +.member:hover .memberModel, +.member:focus-visible .memberModel { + display: inline; +} + +/* The member card is denser than the surfaces the standard capsule ships for, + so the current-conversation tag drops one step in size. The 999 shrink + factor collapses the tag (block display re-enables text-overflow) before + the member name gives up any width. */ +.currentTag { + display: inline-block; + flex: 0 999 auto; + min-width: 0; + padding: 0 4px; + overflow: hidden; + font-size: 10px; + line-height: 15px; + text-overflow: ellipsis; +} + .memberText small, .meta { color: var(--dsw-alias-label-tertiary); @@ -130,11 +237,19 @@ gap: 7px; } +/* Empty task board: a plain description in place of the section heading, + flush with the body's bottom padding. */ +.emptyNotice { + margin: 16px 0 0 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + .task { - padding: 9px; - border: 0.5px solid var(--dsw-alias-border-l1); + padding: 11px 13px; + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 9px; - background: var(--dsw-alias-bg-base); + background: var(--dsw-alias-bg-layer-2); } .taskState { @@ -150,13 +265,54 @@ margin: 5px 0; color: var(--dsw-alias-label-secondary); font-size: 12px; + line-height: 18px; white-space: pre-wrap; } +.clampedDescription { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.expandToggle { + display: inline-flex; + align-items: center; + gap: 2px; + /* Floated ahead of the meta spans: it keeps the first line's top-right + corner, so wrapped meta lines continue below it instead of pushing the + toggle under content it expands. */ + float: right; + margin-left: 10px; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 20px; + cursor: pointer; +} + +.expandToggle:hover, +.expandToggle:focus-visible { + color: var(--dsw-alias-label-primary); +} + +.expandToggle svg { + transition: transform 120ms ease; +} + +.expandToggleOpen { + transform: rotate(180deg); +} + .meta { - display: flex; - flex-wrap: wrap; - gap: 4px 10px; + line-height: 20px; +} + +.meta > span { + margin-right: 10px; } .notice, diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 5a4dd88b65..57a61548e9 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -7,33 +7,35 @@ import type { } from '@deepseek-ai/dsh-experimental-agent-team/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { - IconCloseOutlineRegular, IconUserOutlineRegular, StateDot, + IconChevronDownOutlineRegular, + IconUserOutlineRegular, IconUsersOutlineRegular, StateDot, Tag, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type TeamKey } from './locales.ts' import css from './TeamAction.module.css' /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - /** Open a continuable child from the current conversation. */ + /** Open a roster Session from the current conversation. */ openTeammate: (sessionId: SessionId, childSessionId: SessionId) => void } +/** Durable lifecycle overlaid with the member Session's live turn activity. */ +type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' + /** Full props of the Team conversation-header action. */ export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale -/** Durable lifecycle overlaid with the member Session's live turn activity. */ -type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' - function statusKey(status: TeamTask['status']): TeamKey { switch (status) { case 'pending': return 'status.pending' case 'in_progress': return 'status.in_progress' case 'completed': return 'status.completed' - /* v8 ignore next -- the Team projection omits deleted task tombstones. */ + /* v8 ignore next -- Team views omit deleted task tombstones. */ case 'deleted': return 'status.completed' } } @@ -47,11 +49,10 @@ function memberStatusKey(status: MemberStatus): TeamKey { } } -function memberDotState(status: MemberStatus): StateDotState { +function memberDotState(status: Exclude): StateDotState { switch (status) { case 'running': case 'provisioning': return 'ongoing' - case 'inactive': return 'idle' case 'failed': return 'error' } } @@ -61,7 +62,7 @@ function taskDotState(task: TeamTask): StateDotState { case 'pending': return task.ready ? 'idle' : 'warning' case 'in_progress': return 'ongoing' case 'completed': return 'done' - /* v8 ignore next -- the Team projection omits deleted task tombstones. */ + /* v8 ignore next -- Team views omit deleted task tombstones. */ case 'deleted': return 'idle' } } @@ -70,11 +71,12 @@ type TeamMemberRowProps = Pick & { member: TeamMemberProjection + memberCount: number onError: (message: string) => void } function TeamMemberRow({ - member, sessionId, useSessions, useSessionStatus, openTeammate, onError, t, + member, memberCount, sessionId, useSessions, useSessionStatus, openTeammate, onError, t, }: TeamMemberRowProps) { const model = useSessions(state => state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) const running = useSessionStatus(state => state.get(member.id)?.running) @@ -82,32 +84,93 @@ function TeamMemberRow({ const status: MemberStatus = member.phase === 'active' ? (running ?? summaryRunning) === true ? 'running' : 'inactive' : member.phase + const isCurrent = member.id === sessionId + const highlightCurrent = isCurrent && memberCount > 1 + const inert = isCurrent || status === 'failed' || status === 'provisioning' return ( - + + + ) } -/** Render the Team roster and read-only task board from the Lead Session's `agentTeam` projection. */ +/** Task card with a two-line description clamp expanded from a toggle in the meta row. */ +function TaskCard({ task, t }: { task: TeamTask; t: TranslateNS }) { + const [expanded, setExpanded] = useState(false) + const [clamped, setClamped] = useState(false) + const textRef = useRef(null) + useLayoutEffect(() => { + if (expanded) return + const paragraph = textRef.current + /* v8 ignore next -- the paragraph mounts in the same commit as the effect. */ + if (paragraph === null) return + setClamped(paragraph.scrollHeight > paragraph.clientHeight + 1) + }, [task.description, expanded]) + return ( +
+
+ {task.subject} + + + {t(statusKey(task.status))} + +
+

{task.description}

+
+ {(clamped || expanded) && ( + + )} + {task.id} + {t('owner')}: {task.ownerName ?? t('unowned')} + {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} + {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} + {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} + {task.writeScopeWarnings.map(warning => {warning})} +
+
+ ) +} + +/** Render the Team roster and read-only task board. */ export function TeamAction({ sessionId, useSession, useSessions, useSessionStatus, openTeammate, t, }: TeamActionProps) { @@ -115,133 +178,172 @@ export function TeamAction({ const [error, setError] = useState(null) const rootRef = useRef(null) const triggerRef = useRef(null) + const triggerLabelRef = useRef(null) const panelRef = useRef(null) const position = useAnchoredPosition({ open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16, }) const positioned = position !== null - useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) - const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId const team = useSessions(state => state.projectionsBySession[leadSessionId]?.values.agentTeam) const opening = useSession(snapshot => snapshot.openState === 'loading') const listing = useSessions(state => state.phase === 'pending') + const hoverTimer = useRef | undefined>(undefined) + const pinnedRef = useRef(false) + + const cancelHoverChange = (): void => { + clearTimeout(hoverTimer.current) + hoverTimer.current = undefined + } useEffect(() => { + cancelHoverChange() + pinnedRef.current = false setOpen(false) setError(null) }, [sessionId]) + useEffect(() => cancelHoverChange, []) + useLayoutEffect(() => { - if (open && positioned) panelRef.current?.focus() + if (open && positioned && pinnedRef.current) panelRef.current?.focus() }, [open, positioned]) - const close = (): void => { - setOpen(false) - triggerRef.current?.focus() + const changeOpen = (next: boolean): void => { + cancelHoverChange() + if (!next) pinnedRef.current = false + setOpen(next) } - const teammates = team?.members.filter(member => member.role === 'teammate') ?? [] + const scheduleHoverOpen = (): void => { + cancelHoverChange() + if (open) return + const label = triggerLabelRef.current + /* v8 ignore next -- the label mounts with the trigger that received the hover. */ + if (label === null) return + // Icon-only trigger (label collapsed by the header container query): + // hover-open would surprise on such a small target, so only click opens. + if (getComputedStyle(label).display === 'none') return + hoverTimer.current = setTimeout(() => { + hoverTimer.current = undefined + changeOpen(true) + }, 150) + } + + const scheduleHoverClose = (): void => { + cancelHoverChange() + if (pinnedRef.current) return + hoverTimer.current = setTimeout(() => { + hoverTimer.current = undefined + changeOpen(false) + }, 120) + } + + useDismissOnOutsidePointer(rootRef, open, changeOpen, panelRef) + + useEffect(() => { + if (!open) return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + event.preventDefault() + cancelHoverChange() + pinnedRef.current = false + setOpen(false) + if (panelRef.current?.contains(document.activeElement)) triggerRef.current?.focus() + } + document.addEventListener('keydown', dismiss) + return () => { document.removeEventListener('keydown', dismiss) } + }, [open]) + + const compact = team !== undefined && team.members.length === 1 && team.tasks.length === 0 return ( -
{ - if (event.key !== 'Escape' || !open) return - event.preventDefault() - close() - }} onBlur={(event) => { - const target = event.relatedTarget - if (target instanceof Node && !event.currentTarget.contains(target) && !panelRef.current?.contains(target)) { - setOpen(false) - } - }}> +
{open && createPortal(
-
- {t('trigger')} - - +
+ {error !== null && ( +
{error}
+ )} + {team === undefined && ( +
+ + {t(opening || listing ? 'loading' : 'unavailable')} +
+ )} + {team !== undefined && ( + <> + {team.failure !== undefined && ( +
{t('failure', { message: team.failure })}
+ )} +
+

+ {t('roster')} + {team.members.length > 1 && {team.members.length}} +

+
+ {team.members.map(member => ( + + ))} +
+
+
+ {team.tasks.length === 0 + ?

{t('empty')}

+ : ( + <> +

{t('tasks')}{team.tasks.length}

+
+ {team.tasks.map(task => )} +
+ + )} +
+ + )}
- {error !== null && ( -
{error}
- )} - {team === undefined && ( -
- - {t(opening || listing ? 'loading' : 'unavailable')} -
- )} - {team !== undefined && ( - <> - {team.failure !== undefined && ( -
{t('failure', { message: team.failure })}
- )} -
-

{t('roster')}

-
- {team.members.map(member => ( - - ))} -
-
-
-

{t('tasks')}

- {team.tasks.length === 0 &&
{t('empty')}
} -
- {team.tasks.map(task => ( -
-
- {task.subject} - - - {t(statusKey(task.status))} - -
-

{task.description}

-
- {task.id} - {t('owner')}: {task.ownerName ?? t('unowned')} - {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} - {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} - {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} - {task.writeScopeWarnings.map(warning => {warning})} -
-
- ))} -
-
- - )}
, document.body, )} diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 14d1eb0ed7..3ae022d753 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -5,22 +5,24 @@ export const NS = 'agent-team' /** Simplified Chinese dictionary and key source. */ export const zh = { - trigger: 'Agent Team', - close: '关闭', - loading: '正在加载 Team…', + trigger: '智能体团队', + loading: '正在加载团队…', unavailable: 'Team 暂不可用', - failure: 'Team 持久记录无效:{message}', - empty: '还没有共享任务', + failure: '团队持久记录无效:{message}', + empty: '暂无共享任务,可以通过对话创建', roster: '成员', tasks: '共享任务', model: '模型', - open: '打开 teammate 会话', + open: '打开成员会话', + current: '当前会话', owner: 'Owner', unowned: '未分配', blockedBy: '依赖', writeScopes: '写入范围', ready: '可开始', blocked: '被依赖阻塞', + 'task.expand': '展开', + 'task.collapse': '收起', 'memberStatus.running': '运行中', 'memberStatus.inactive': '未运行', 'memberStatus.provisioning': '准备中', @@ -36,21 +38,23 @@ export type TeamKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { trigger: 'Agent Team', - close: 'Close', loading: 'Loading Team…', unavailable: 'Team is unavailable', failure: 'Invalid persisted Team record: {message}', - empty: 'No shared tasks yet', + empty: 'No shared tasks yet, you can create in the conversation', roster: 'Members', tasks: 'Shared tasks', model: 'Model', open: 'Open teammate conversation', + current: 'Current chat', owner: 'Owner', unowned: 'Unowned', blockedBy: 'Blocked by', writeScopes: 'Write scopes', ready: 'Ready', blocked: 'Blocked by dependencies', + 'task.expand': 'Show more', + 'task.collapse': 'Show less', 'memberStatus.running': 'Running', 'memberStatus.inactive': 'Inactive', 'memberStatus.provisioning': 'Provisioning', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 4ca1b5e04c..94a369df04 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -38,6 +38,10 @@ export function registerAgentTeamUi(ctx: ClientContext): void { openTeammate(sessionId: SessionId, childSessionId: SessionId): void { const parentSessionId = leadSessionId(sessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return + if (childSessionId === parentSessionId) { + ctx.uiWorkspace.openSession(parentSessionId) + return + } ctx.uiWorkspace.openSession({ parentSessionId, childSessionId, @@ -51,7 +55,7 @@ export function registerAgentTeamUi(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'agent-team', - order: 20, + order: -20, locale: NS, inject: () => actions, }, TeamAction), diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index abc786646f..23c5844747 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -78,7 +78,7 @@ describe('ui-team browser plugin', () => { const b = await bench() expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) expect(b.entry()).toMatchObject({ - options: { id: 'agent-team', order: 20 }, + options: { id: 'agent-team', order: -20 }, locale: 'agent-team', }) const t = b.ctx.locale.bind('agent-team') @@ -107,6 +107,12 @@ describe('ui-team browser plugin', () => { ]) }) + it('opens the Lead from an addressed teammate conversation', async () => { + const b = await bench({ addressed: true }) + b.actions().openTeammate(CHILD, SESSION) + expect(b.navigation).toEqual([['open', SESSION]]) + }) + it('does not open a teammate from a conversation outside the main view', async () => { const b = await bench() b.select('other-session' as SessionId) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 3aca5c5cc8..0757f28362 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -15,7 +15,11 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx' import { zh } from '../src/client/locales.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) const SESSION = 'lead' as SessionId const WORKER = 'worker-id' as SessionId @@ -99,7 +103,7 @@ function bench(options: { } function openPanel(): void { - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + fireEvent.click(screen.getByRole('button', { name: /智能体团队/u })) } function setProjectionSnapshot( @@ -121,7 +125,7 @@ describe('TeamAction', () => { it('renders the Lead projection and applies later projection frames without any user action', async () => { const b = bench() render() - expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('1') + expect(screen.getByRole('button', { name: /智能体团队/u }).textContent).toBe(zh.trigger) openPanel() expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() @@ -133,7 +137,7 @@ describe('TeamAction', () => { }) expect(screen.getByText('Pushed task')).toBeTruthy() expect(screen.getByRole('button', { name: /worker-b/u })).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: /Agent Team/u }).textContent).toContain('2') + expect(screen.getByRole('heading', { name: '成员3' })).toBeTruthy() }) it('overlays live Session status and the durable model selection on roster rows', () => { @@ -154,18 +158,18 @@ describe('TeamAction', () => { }) render() openPanel() - expect(screen.getByRole('button', { name: `lead${zh['memberStatus.running']} · ${zh.model}: lead-model` })).toBeTruthy() - const row = screen.getByRole('button', { name: `worker${zh['memberStatus.running']} · ${zh.model}: worker-model` }) + expect(screen.getByRole('button', { name: new RegExp(`lead.*${zh['memberStatus.running']}.*lead-model`, 'u') })).toBeTruthy() + const row = screen.getByRole('button', { name: new RegExp(`worker.*${zh['memberStatus.running']}.*worker-model`, 'u') }) expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() act(() => { b.statuses.set(new Map([[WORKER, { running: false, pendingInteraction: undefined, completionUnread: false }]])) }) - expect(screen.getByRole('button', { name: /^worker未运行/u })).toBeTruthy() + expect(screen.getByRole('button', { name: /^worker.*未运行/u })).toBeTruthy() act(() => { b.statuses.set(new Map()) b.sessions.update((draft) => { draft.byId[WORKER] = summary(WORKER, true) }) }) - expect(screen.getByRole('button', { name: /^worker运行中/u })).toBeTruthy() + expect(screen.getByRole('button', { name: /^worker.*运行中/u })).toBeTruthy() }) it('reads the Lead projection from an addressed teammate conversation', () => { @@ -173,8 +177,9 @@ describe('TeamAction', () => { render() openPanel() expect(screen.getByText('Implement runtime')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: /^worker/u })) - expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, WORKER) + expect(screen.getByRole('button', { name: /^worker/u }).disabled).toBe(true) + fireEvent.click(screen.getByRole('button', { name: /^lead/u })) + expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, SESSION) }) it.each([false, true])('accepts shared baselines and late capability updates (teammate page: %s)', (addressed) => { @@ -256,7 +261,7 @@ describe('TeamAction', () => { }) render() openPanel() - expect(screen.getByRole('alert').textContent).toBe('Team 持久记录无效:revision is not contiguous') + expect(screen.getByRole('alert').textContent).toBe('团队持久记录无效:revision is not contiguous') expect(screen.getByText('Implement runtime')).toBeTruthy() }) @@ -306,9 +311,9 @@ describe('TeamAction', () => { fireEvent.click(screen.getByRole('button', { name: /^worker/u })) expect(screen.getByRole('alert').textContent).toBe('Error: navigation failed') - fireEvent.click(screen.getByRole('button', { name: zh.close })) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) + expect(document.activeElement).toBe(screen.getByRole('button', { name: /智能体团队/u })) }) it('closes the panel and clears a navigation failure when the conversation switches sessions', () => { @@ -330,7 +335,7 @@ describe('TeamAction', () => { it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { const b = bench() const rendered = render() - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + const trigger = screen.getByRole('button', { name: /智能体团队/u }) fireEvent.click(trigger) const panel = screen.getByRole('dialog') expect(rendered.container.contains(panel)).toBe(false) @@ -342,28 +347,161 @@ describe('TeamAction', () => { fireEvent.pointerDown(document.body) expect(screen.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) - fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('button', { name: /^worker/u }), { key: 'Enter' }) expect(screen.queryByRole('dialog')).not.toBeNull() - fireEvent.keyDown(screen.getByRole('button', { name: zh.close }), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('button', { name: /^worker/u }), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(trigger) fireEvent.keyDown(trigger, { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() }) - it('keeps focus within the trigger or panel and closes when focus moves elsewhere', () => { + it('clamps a long task description to two lines behind an expand toggle', async () => { + const scrollHeight = vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(120) + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(60) + try { + render() + fireEvent.click(screen.getByRole('button', { name: /智能体团队/u })) + const expand = await screen.findByRole('button', { name: zh['task.expand'] }) + const description = screen.getByText('Build the Team runtime') + expect(expand.getAttribute('aria-expanded')).toBe('false') + expect(description.className).not.toBe('') + + fireEvent.click(expand) + const collapse = screen.getByRole('button', { name: zh['task.collapse'] }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Build the Team runtime').className).toBe('') + + fireEvent.click(collapse) + expect(screen.getByRole('button', { name: zh['task.expand'] })).toBeTruthy() + } finally { + scrollHeight.mockRestore() + clientHeight.mockRestore() + } + }) + + it('opens on hover and preserves the trigger-to-panel crossing grace', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } const b = bench() - render(<>) - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + const rendered = render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + + fireEvent.mouseEnter(root) + await advance(149) + expect(screen.queryByRole('dialog')).toBeNull() + await advance(1) + const panel = screen.getByRole('dialog') + + fireEvent.mouseEnter(root) + expect(screen.getByRole('dialog')).toBe(panel) + + fireEvent.mouseLeave(root) + fireEvent.mouseEnter(panel) + await advance(120) + expect(screen.getByRole('dialog')).toBe(panel) + + fireEvent.mouseLeave(panel) + await advance(119) + expect(screen.getByRole('dialog')).toBe(panel) + await advance(1) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + rendered.unmount() + await advance(150) + }) + + it('pins a click-opened panel through hover-out until explicit dismissal', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + fireEvent.click(trigger) const panel = screen.getByRole('dialog') - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.close }) }) + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: trigger }) + fireEvent.mouseEnter(panel) + fireEvent.mouseLeave(panel) + await advance(300) expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: null }) - expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: 'Outside' }) }) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.mouseEnter(root) + await advance(150) + const hovered = screen.getByRole('dialog') + fireEvent.click(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('dialog')).toBe(hovered) + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.mouseEnter(root) + fireEvent.mouseLeave(root) + await advance(300) expect(screen.queryByRole('dialog')).toBeNull() }) + + it('opens by click only while the trigger is collapsed to its icon', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + const label = screen.getByText(zh.trigger) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => + element === label ? { display: 'none' } as CSSStyleDeclaration : computedStyle(element)) + + fireEvent.mouseEnter(root) + await advance(300) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('keeps projection updates live without polling while pinned', async () => { + vi.useFakeTimers() + const b = bench() + render() + openPanel() + await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Pushed while pinned' }] }) + expect(screen.getByText('Pushed while pinned')).toBeTruthy() + }) + + it('keeps an accessible trigger name when its label is collapsed', () => { + render() + screen.getByText(zh.trigger).style.display = 'none' + expect(screen.getByRole('button', { name: zh.trigger })).toBeTruthy() + }) + + it('dismisses a hovered panel with Escape without stealing composer focus', async () => { + vi.useFakeTimers() + render(<>