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/.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..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: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc -2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f +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 45c7e599e0..6333e511ba 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,14 @@ 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 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. + +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 **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..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 @@ -54,6 +54,14 @@ 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` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。 + +传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 + +发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 + ## 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-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index c8be11b929..911b763592 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -389,8 +389,7 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - // The preset label leads the header band; delegation navigation follows it. - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 3e5ad5e0c2..8358ef1a20 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,11 +1,11 @@ // 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' 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' @@ -18,6 +18,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') +const RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md') const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) const INSTALL_ANCHORS = [ @@ -80,26 +81,29 @@ 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')) + 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('No shared tasks yet. Create them through the conversation.').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) + // 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) @@ -107,9 +111,10 @@ 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() + await page.keyboard.press('Escape') }, 60_000) it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { @@ -131,35 +136,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: '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') 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('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 { @@ -167,7 +161,50 @@ describe('web e2e: Agent Teams panel', () => { } }) + it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => { + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + await trigger.hover() + await panel.waitFor() + await panel.hover() + await trigger.hover() + // The assertion spans the 120ms dismissal grace while the pointer stays on the trigger. + await page.waitForTimeout(180) + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') + }) + + it('offers expansion when resizing makes a task description exceed two lines', async () => { + const agent = scaffold.ctx.agents.list()[0]! + await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Responsive task', + description: 'Check the task description before you start work. '.repeat(3), + writeScopes: [], + }) + const viewport = page.viewportSize()! + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const card = panel.locator('article').filter({ hasText: 'Responsive task' }) + await trigger.click() + await card.waitFor() + expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + try { + await page.setViewportSize({ width: 360, height: 700 }) + await card.getByRole('button', { name: 'Show more' }).waitFor() + expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true) + await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE) + await card.getByRole('button', { name: 'Show more' }).click() + expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true) + await card.getByRole('button', { name: 'Show less' }).click() + await page.setViewportSize(viewport) + await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + } + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md']) }) }) 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/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 2b87066124..81cc3c1175 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,5 +1,5 @@ - navigation "Session hierarchy": Seeded turn -- text: Minimal mode - button "1 subagent" +- text: Minimal mode - button "More actions" - button "Open right sidebar" 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-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/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/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/plugin-install-github.e2e.ts b/apps/web/tests/plugin-install-github.e2e.ts index 1cc860d125..1926b6f4e2 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) => { 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 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/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 19a9c96e4d..f16e5adaa8 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() @@ -278,9 +278,12 @@ describe('web e2e: plugin manager', () => { await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') 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('还没有共享任务').waitFor() + await action.getByRole('button', { name: '智能体团队', exact: true }).click() + const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true }) + await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor() + await teamPage.reload({ waitUntil: 'load' }) + await action.getByRole('button', { name: '智能体团队', exact: true }).click() + await teamPanel.getByText('暂无共享任务,可以通过对话创建').waitFor() await teamPanel.getByText('lead', { exact: true }).waitFor() const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { dsh: { profile: { bundles: string[] } } 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/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/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 }) diff --git a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md new file mode 100644 index 0000000000..19f90be9a3 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md @@ -0,0 +1,15 @@ +- dialog "Agent Team": + - heading "Members" [level=3] + - button "lead Current chat Inactive" [disabled] + - heading "Shared tasks 2" [level=3] + - article: + - strong: Agent task + - text: In progress + - paragraph: Created by the Team Lead + - text: "task-1Owner: leadWrite scopes: src/web" + - article: + - strong: Responsive task + - text: Pending + - paragraph: Check the task description before you start work. Check the task description before you start work. Check the task description before you start work. + - button "Show more" + - text: "task-2Owner: UnownedReady" 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..9e6f63532e 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,12 +1,9 @@ - dialog "Agent Team": - - strong: Agent Team - - button "Refresh Team" - - button "Close" - heading "Members" [level=3] - - 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]' - - heading "Shared tasks" [level=3] + - button "lead Current chat Inactive" [disabled] + - heading "Shared tasks 1" [level=3] - article: - strong: Agent task - text: In progress - paragraph: Created by the Team Lead - - text: "task-1 Owner: lead Write scopes: src/web" + - text: "task-1Owner: leadWrite scopes: src/web" diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 1b8a432cef..14c368b72f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -404,7 +404,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens child history in the right Sidebar and releases it when closed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat')) await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).click() const sidebarChat = page.locator('[data-sidebar-chat]') await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( @@ -416,7 +416,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sidebarChat.waitFor({ state: 'detached' }) await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click() + await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar`, exact: true }).click() await page.locator('[data-sidebar-chat]').getByText( 'One-shot tasks do not accept follow-ups; review the full execution record here.', ).waitFor({ timeout: 15_000 }) @@ -532,16 +532,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await nestedDisclosure.count()).toBe(0) const clickArea = nestedRow.locator(':scope > div') expect(await clickArea.count()).toBe(1) - const [treeBox, clickAreaBox] = await Promise.all([ - tree.boundingBox(), + const [menuBox, clickAreaBox] = await Promise.all([ + tree.locator('..').boundingBox(), clickArea.boundingBox(), ]) - expect(treeBox).not.toBeNull() + expect(menuBox).not.toBeNull() expect(clickAreaBox).not.toBeNull() expect([ - Math.round(clickAreaBox!.x - treeBox!.x), - Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - // Compact menu padding alone insets the rows now that the border is gone. + Math.round(clickAreaBox!.x - menuBox!.x), + Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width), + // The outer menu padding insets the scrollable tree rows. ]).toEqual([3, 3]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9faa3d8f76..884f160418 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -225,6 +225,30 @@ export async function saveFailureShot(page: Page, name: string): Promise { } } +/** + * Assert a visible tooltip paints above the element a user would read through + * 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 { + const probes = await tooltip.evaluate((bubble) => { + const rect = bubble.getBoundingClientRect() + const previous = bubble.style.pointerEvents + bubble.style.pointerEvents = 'auto' + 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 probes + }) + expect(probes).toEqual({ center: 'tooltip', bottom: 'tooltip' }) +} + /** * 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/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ea2247432b..13405e808b 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: 6550250f0cc89ea5ca1c67c07894bc04385f93f6 -capability-seams.zh.md: f71f021a32f07eec129e892f8d65445ad7b8d264 +capability-seams.md: 2914231ceaaa243b0c918a9477e554aa1641ad91 +capability-seams.zh.md: a1223a351c26d668705df327102ac5e7e521f9ec diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6550250f0c..2914231cea 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.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"] @@ -419,7 +418,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 @@ -643,7 +641,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 f71f021a32..a1223a351c 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -234,7 +234,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"] @@ -421,7 +420,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 @@ -645,7 +643,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 0d1ac08ae6..1597df9987 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: 37024a43c06e40015b8cee061a957d86ff27416c -config-catalog.zh.md: b73c2cba6b91dc1dd0f181f32f51c42832720cc3 +config-catalog.md: 5749158386933f39c329559016499179735eaea2 +config-catalog.zh.md: d43b9f98529c926f61b7ea1ecbe3a37bf0cb4715 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 37024a43c0..5749158386 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:130`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts) @@ -2169,16 +2169,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 /** @@ -2190,7 +2192,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 b73c2cba6b..d43b9f9852 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:130`](../packages/experimental/agent-team/src/types.ts) +来源: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts) @@ -2171,16 +2171,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 /** @@ -2192,7 +2194,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/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index fbba110cad..836921bc9b 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: 3d7ad447253fa8748bb5f9fa5a19c656bc13cc72 -module-graph.zh.md: 0c3c74fcb49711b070106d74467f51de2608453e +module-graph.md: 5dee52429dc8261dcb5d33db1962360bed1b52a5 +module-graph.zh.md: e4f62aba0b39ee1ba9cdd12babfee1eeb4f07dcd diff --git a/docs/module-graph.md b/docs/module-graph.md index 3d7ad44725..5dee52429d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1250,7 +1250,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 @@ -1275,7 +1274,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 @@ -1286,7 +1284,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 @@ -1642,13 +1639,13 @@ 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) | | [`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 0c3c74fcb4..e4f62aba0b 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1252,7 +1252,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 @@ -1277,7 +1276,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 @@ -1288,7 +1286,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 @@ -1644,13 +1641,13 @@ 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) | | [`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 207b82e845..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: 022944bea076f49e054e7194e6b475cf6dfca2bc -persistence-catalog.zh.md: 096bf746085d329427dfb5516db4c6387f0974fe +persistence-catalog.md: 200e7156f65c4cba6fab6808a84326e85df69ddd +persistence-catalog.zh.md: fa48684d4456c6338b67d58bc051c3672d6ee2c9 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 022944bea0..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:210`](../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: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:238`](../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:236`](../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: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:106`](../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: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: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:210`](../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:214`](../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: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: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:212`](../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 096bf74608..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:210`](../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:216`](../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:214`](../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:212`](../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:106`](../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:115`](../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:210`](../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:214`](../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:216`](../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:212`](../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 4695717a2d..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:216" + "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:115" + "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:106" + "packages/experimental/agent-team/src/types.ts:128" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:212" + "packages/experimental/agent-team/src/types.ts:234" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:210" + "packages/experimental/agent-team/src/types.ts:232" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:214" + "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 6a04e1377d..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: 2f49e164645e6b66bd0da4946c477a09c412ddba -agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810 +agent-team.md: 6cb503d130727b4c4694877f83c02f2e0983c8af +agent-team.zh.md: 083b9fafb6f45cd09ded84807da3c8602193b3d4 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 2f49e16464..6cb503d130 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,56 @@ 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 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 +223,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..083b9fafb6 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,56 @@ 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 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 +223,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/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 00cd046aaa..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: a1e8917adaf80315d871081740e5a76e5cd1e6dd -boot.zh.md: 1ba455d6ba9ff0053ff9d6f6d1313e7ae871a2a1 +boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7 +boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index a1e8917ada..6ec2204ffe 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -129,7 +129,9 @@ 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 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. @@ -148,7 +150,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..f5fde00f0c 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -129,7 +129,9 @@ 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 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. @@ -148,7 +150,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..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: 1de9c9d78e421d44eb006f1ab7fd82656f006a2a -README.zh.md: f7502aab64738ad1feccf58b84ff945213df2eff +README.md: 803566c8650df7186c5344945ea0fe2359e8e70c +README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 1de9c9d78e..803566c865 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 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. 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..657e4587e2 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'`。认证、仓库查找及其他失败继续交给 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) 拥有理由。 可在浏览器使用的 `@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..99b64fd73c --- /dev/null +++ b/packages/boot/plugin-manager/src/github-connection.ts @@ -0,0 +1,68 @@ +/** 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 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 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, +): 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', ['-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(`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 ?? (result.code === 'ENOENT' ? 127 : 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..93d0287b9e 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,9 @@ 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 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. @@ -418,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 } }) @@ -432,12 +440,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?.kind === 'network' || connectionFailure?.kind === 'timeout') { + result.packageResult = connectionFailure + 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 +532,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/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/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..673fb7c733 --- /dev/null +++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts @@ -0,0 +1,183 @@ +/** 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 { createServer as createHttpServer } from 'node:http' +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 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, + }) + tasks.push(task) + return task + } + return { dir, config, controller, check, env } +} + +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) => { 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('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') +}) + +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 16fbed9650..5c372a07a8 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1,9 +1,13 @@ /** 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' +import { execa } from 'execa' import type { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' import { @@ -17,6 +21,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 +65,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 +74,155 @@ 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('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 })) +}) + +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 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: 'cancelled', changed: false }) + expect(pnpm).not.toHaveBeenCalled() +}) + +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) => { const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => { 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-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-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-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index b91ee92562..03e39bf92e 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/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-jobs/README.md -README.md: 6f2b856a970cc869676bbda57d749cf4fb7ceba0 -README.zh.md: b99723e841cfb3d6c52ba0ead7e1ab4dd1952e51 +README.md: 0c530b802b502ea9db3135a711ed5b643584a79e +README.zh.md: 86ac2890111c098d01d5a7bc2997f99e1aa4065f diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 6f2b856a97..0c530b802b 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
Implementation internals — click to expand -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. +One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index b99723e841..86ac289011 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 780b423555..91f93a2442 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -37,8 +37,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'job-list', - // Between the preset label and the subagent catalog (order 30): running - // work reads before the session lineage. + // Background work follows the preset label in the header actions band. order: 20, locale: NS, inject: (): JobListInjected => ({ diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 568cf01093..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: d89e56a1115d077865c49150ecbf4991a524cfe9 -README.zh.md: 50699e47490425f34eb295bec057ea970b84b7e5 +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 d89e56a111..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. @@ -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 @@ -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 50699e4749..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 信息。 @@ -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 仓库下载。 ### 切换一个组合包 @@ -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/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/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index ce99c88c48..96485654df 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: 73b3da1aa827a02e978516e37d618b9569253dd7 -README.zh.md: 080852d90b017124acd5eefecb49f12452626a0c +README.md: 7d474aeb0d63991879825188f4a702bbad40f343 +README.zh.md: a0423daa88497b21238b2747395b163bf0309063 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 73b3da1aa8..7d474aeb0d 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 080852d90b..a0423daa88 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..0f7dd5bae3 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,8 +46,11 @@ 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 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 @@ -58,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. @@ -79,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). @@ -107,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' } @@ -127,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) => (
+ { changeOpen(false) }} + t={t} + /> +
), document.body)} @@ -724,7 +735,7 @@ export type SubagentCatalogActionProps = /** * Session-header catalog action for root sessions: the descendant count and - * its dropdown, ordered after the task list. Child sessions render nothing + * its dropdown at the start of the header actions band. Child sessions render nothing * here — their breadcrumb switcher in the lineage slot owns the same * navigation. * @param props - Session standard props plus the catalog actions and translator. @@ -769,7 +780,7 @@ export function SubagentHeaderLineage({ }) const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t } // Root sessions carry no breadcrumb; their descendant count lives in the - // header actions band (SubagentCatalogAction), after the task list. + // header actions band (SubagentCatalogAction). if (parentId === undefined) return null return ( <> 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..2ad74f6a7f 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -37,7 +37,8 @@ export const zh = { 'count.running.other': '{count} 个子代理,正在运行', 'switcher.aria': '切换子代理:{title}', 'tree.aria': '子代理会话', - 'open.sidebar': '在侧边栏打开 {label}', + 'open.sidebar': '在侧边栏打开', + 'open.sidebar.aria': '在侧边栏打开 {label}', 'sidebar.chat': '聊天', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', @@ -79,7 +80,8 @@ 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', + 'open.sidebar.aria': 'Open {label} 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..b5ec7aba9a 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -132,10 +132,8 @@ function summary(id: SessionId, updatedAt: number): SessionSummary { } function hoverCatalog(trigger: HTMLElement): void { - const root = trigger.parentElement - if (root === null) throw new Error('catalog trigger has no root') vi.useFakeTimers() - fireEvent.mouseEnter(root) + fireEvent.mouseEnter(trigger) act(() => { vi.advanceTimersByTime(150) }) } @@ -255,7 +253,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: '在侧边栏打开 worker' }) fireEvent.keyDown(sidebarButton, { key: 'Enter' }) fireEvent.click(sidebarButton) expect(input.openChildAside).toHaveBeenCalledWith({ @@ -331,7 +330,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 +340,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!) + fireEvent.mouseEnter(trigger) 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 +368,89 @@ 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(trigger) + 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(trigger) + 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(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.queryByRole('tree')).toBeNull() + }) + + it('keeps the hover catalog open when the pointer returns directly to its trigger', async () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + fireEvent.mouseOver(trigger, { relatedTarget: document.body }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + const menu = screen.getByRole('tree').parentElement! + fireEvent.mouseOut(trigger, { relatedTarget: menu }) + fireEvent.mouseOver(menu, { relatedTarget: trigger }) + fireEvent.mouseOut(menu, { relatedTarget: trigger }) + fireEvent.mouseOver(trigger, { relatedTarget: menu }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + expect(screen.getByRole('tree').parentElement).toBe(menu) + }) + + it('cancels pending hover dismissal when the catalog trigger is activated from the keyboard', async () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + fireEvent.mouseOver(trigger, { relatedTarget: document.body }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + fireEvent.mouseOut(trigger, { relatedTarget: document.body }) + fireEvent.click(trigger, { detail: 0 }) + await act(async () => { await vi.advanceTimersByTimeAsync(120) }) + expect(screen.getByRole('tree')).toBeTruthy() + }) + 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() }) @@ -399,7 +459,7 @@ describe('SubagentHeaderLineage', () => { const view = render() const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) - fireEvent.mouseEnter(trigger.parentElement!) + fireEvent.mouseEnter(trigger) await vi.advanceTimersByTimeAsync(149) view.rerender() expect(screen.queryByRole('button')).toBeNull() 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..b7c3f6eb2e 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -90,6 +91,14 @@ describe('DetailsRow', () => { expect(inspect).toHaveBeenCalledOnce() }) + it('presents teammate-coordination tools under the two-person team icon', () => { + const inspect = vi.fn() + const expectedIcon = render().container.querySelector('svg')!.outerHTML + const view = render([0]} />) + expect(screen.getByText('Wait for teammates')).toBeTruthy() + expect(view.container.querySelector('svg')?.outerHTML).toBe(expectedIcon) + }) + 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/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 52019ce198..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: 6ae7d82214ca9e041324e353057ba6850675c043 -README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e +README.md: 6d2aa6041039022e6508541fdb738181bbb54045 +README.zh.md: 9ea2977a976583aac08ca8bc53d61ff4812e1766 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 6ae7d82214..6d2aa60410 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,11 @@ Read these pages when the package-level contract is not enough. They move from t -### Browser Remote +### Browser projection -`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 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. ## Model Experience @@ -198,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 89176c285a..9ea2977a97 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,11 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch -### 浏览器 Remote +### 浏览器投影 -`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。 +`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、任务与投影类型。 ## 模型体验 @@ -198,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 cb54ce3b73..680ea5c4c4 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 b1fd173d10..c56076fa7c 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -1,9 +1,11 @@ -/** 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, - 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/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..bc9239d3d8 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,117 @@ 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']), + 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, + ...member.error === undefined ? {} : { error: member.error }, + }) + } + return { + members, + tasks: state.tasks + .filter(task => task.status !== 'deleted') + .map(task => projectTaskView(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. + * 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. + */ +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..4b6132526a 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', @@ -68,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) }) } @@ -83,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) } /** @@ -96,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)) } /** @@ -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 @@ -210,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) }) } @@ -251,47 +247,9 @@ 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. - */ - 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], - } - } } 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..569ff38261 --- /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 { 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' + +/** + * 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. + */ +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 state - Team state supplying members and sibling tasks. + * @param task - durable task snapshot to view. + * @returns a detached task view. + */ +export function projectTaskView(state: TeamState, task: TeamTaskSnapshot): TeamTaskView { + const ownerName = task.ownerId === undefined + ? undefined + : task.ownerId === brandString(state.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: [...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..9921886f6f 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -96,10 +96,32 @@ export interface TeamTaskView { readonly writeScopeWarnings: string[] } -/** Point-in-time roster and task-board projection returned to browser clients. */ -export interface TeamView { - readonly members: TeamMemberView[] +/** 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 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. */ diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 4e2d762fd6..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' @@ -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/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..2266b41483 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' }, + ], + 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', + }) + + 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..af939b0108 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -22,17 +22,19 @@ 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 }) }) /** 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) @@ -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) @@ -873,24 +877,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']) @@ -1364,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/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 5965f254f5..3524cb25a3 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: 17990a84ebe398b632e2e6224674efe09f401aa9 +README.zh.md: 90bed11f2886429f39d2e6b73d251df63256dab4 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 253c243a68..17990a84eb 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,15 @@ 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 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. Provisioning and running members use the shared ongoing loader, inactive members use a person icon, and failed members use error. 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. +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. ----- @@ -45,14 +47,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. Member cards use the shared elevation stroke for their outlines in resting, selected, and hover states. 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 | |---|---| -| [`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, 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 +66,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, 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. @@ -83,7 +85,9 @@ 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. +- **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. @@ -97,4 +101,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..90bed11f28 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,15 @@ 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 字段。 +面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 + +Roster 行展示持久名称与阶段。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用人物图标,failed 成员使用 error 红点。实时 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 agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 ----- @@ -45,14 +47,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。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 +面板渲染在会话容器外,并保持在视口范围内。成员卡片在静止、选中和悬停状态下均使用共享 elevation 描边绘制轮廓。悬停触发按钮 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。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| -| [`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 +66,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 与投影行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -83,7 +85,9 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. -- **Snapshot refresh**——panel 会在打开和显式 refresh 时刷新;它没有实时事件订阅或 mailbox timeline。 +- **没有 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。 @@ -97,4 +101,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 8138182114..ef6657b6d7 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.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index 29d35ccaf0..ffd2cd01ec 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,12 @@ 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; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + box-shadow: var(--dsw-elevation-stroke); 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 +148,78 @@ cursor: default; } +/* The Current tag and card share a tint; the inset ring adds emphasis + without changing the card's dimensions. */ +.memberCurrent { + --dsw-elevation-stroke-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); + box-shadow: var(--dsw-elevation-stroke), 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 { + --dsw-elevation-stroke-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 +238,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 +266,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 bce5aa56e0..b418f20336 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -1,38 +1,35 @@ -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, + 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' -/** 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 + /** 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 -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' @@ -43,7 +40,7 @@ function statusKey(status: TeamTask['status']): TeamKey { } } -function memberStatusKey(status: TeamRosterMember['status']): TeamKey { +function memberStatusKey(status: MemberStatus): TeamKey { switch (status) { case 'running': return 'memberStatus.running' case 'inactive': return 'memberStatus.inactive' @@ -52,11 +49,10 @@ function memberStatusKey(status: TeamRosterMember['status']): TeamKey { } } -function memberDotState(status: TeamRosterMember['status']): StateDotState { +function memberDotState(status: Exclude): StateDotState { switch (status) { case 'running': case 'provisioning': return 'ongoing' - case 'inactive': return 'idle' case 'failed': return 'error' } } @@ -71,174 +67,289 @@ function taskDotState(task: TeamTask): StateDotState { } } +type TeamMemberRowProps = Pick & { + member: TeamMemberProjection + memberCount: number + onError: (message: string) => void +} + +function TeamMemberRow({ + 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) + const summaryRunning = useSessions(state => state.byId[member.id]?.running) + 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 ( + + + + ) +} + +/** 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 + const measure = (): void => { setClamped(paragraph.scrollHeight > paragraph.clientHeight + 1) } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(paragraph) + return () => { observer.disconnect() } + }, [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, load, openTeammate, t, + sessionId, useSession, useSessions, useSessionStatus, 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) + 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 sessionRef = useRef(sessionId) - const refreshGeneration = useRef(0) - sessionRef.current = sessionId + 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(() => { - refreshGeneration.current += 1 + cancelHoverChange() + pinnedRef.current = false setOpen(false) - setLoading(false) - setView(null) 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 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 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 teammates = view?.members.filter(member => member.role === 'teammate') ?? [] + 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')} - - {loading && view !== null && ( - +
+ {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}
- )} - {loading && view === null && ( -
{t('loading')}
- )} - {view !== null && ( - <> -
-

{t('roster')}

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

{t('tasks')}

- {view.tasks.length === 0 &&
{t('empty')}
} -
- {view.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/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..fe4b853095 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -5,21 +5,24 @@ export const NS = 'agent-team' /** Simplified Chinese dictionary and key source. */ export const zh = { - trigger: 'Agent Team', - refresh: '刷新 Team', - close: '关闭', - loading: '正在加载 Team…', - empty: '还没有共享任务', + trigger: '智能体团队', + loading: '正在加载团队…', + unavailable: 'Team 暂不可用', + 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': '准备中', @@ -35,20 +38,23 @@ 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', loading: 'Loading Team…', - empty: 'No shared tasks yet', + unavailable: 'Team is unavailable', + failure: 'Invalid persisted Team record: {message}', + empty: 'No shared tasks yet. Create them through the conversation.', roster: 'Members', tasks: 'Shared tasks', model: 'Model', - open: 'Open teammate conversation', + open: 'Open member 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 ad9648e322..94a369df04 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,13 @@ -/** 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 { 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 +17,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 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,16 +35,16 @@ function registerUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - async load(sessionId): Promise> { - return await ctx.remote.agentTeams.view(leadSessionId(sessionId)) - }, - openTeammate(sessionId: SessionId, member: TeamRosterMember): 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 + if (childSessionId === parentSessionId) { + ctx.uiWorkspace.openSession(parentSessionId) + return + } ctx.uiWorkspace.openSession({ parentSessionId, - childSessionId: member.id, + childSessionId, mode: 'continuable', }) }, @@ -58,34 +55,9 @@ function registerUi(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'agent-team', - order: 20, + order: -20, locale: NS, inject: () => actions, }, 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..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 @@ -1,78 +1,20 @@ -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 { 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: [], -} -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 +48,78 @@ 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 = (): TeamActionInjected => { + const injected = entry()!.inject!() + const { openTeammate } = injected + if (typeof openTeammate !== 'function') { + throw new Error('Team header action lacks its injected callbacks') + } + return { + openTeammate: openTeammate as TeamActionInjected['openTeammate'], + } + } 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 }, + 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([]) 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, CHILD) 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, CHILD) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) + 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() - 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, CHILD) 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..11f57e6cc1 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,31 @@ // @vitest-environment jsdom +import { Profiler } from 'react' 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, 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 } 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) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) 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 +38,307 @@ 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', +} +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 remoteFailure(message: string): TeamActionResult { - return { ok: false, error: new RemoteError('gateway/internal', message, {}) } -} - -function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps { - return { +function bench(options: { + projections?: Projections + sessionId?: SessionId + parentSessionId?: SessionId + openState?: SessionSnapshot['openState'] + statuses?: SessionStatusSnapshot + running?: Record +} = {}) { + const sessionId = options.sessionId ?? SESSION + 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) as SessionId[], byId, phase: 'ready', + projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: team } } }, + }) + const statuses = createSnapshotStore(options.statuses ?? new Map()) + const session = createSnapshotStore({ sessionId, - ...actions, + pendingSubmissions: [], + running: false, + subagent: options.parentSessionId === undefined + ? null + : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, + removed: false, + openState: options.openState ?? 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + }) + const useSessions = bindSnapshotSelector(sessions) + const injected: TeamActionInjected = { openTeammate: vi.fn() } + const props: TeamActionProps = { + sessionId, + useSession: bindSnapshotSelector(session), + 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), - } as unknown as TeamActionProps + } 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: /智能体团队/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: /智能体团队/u }).textContent).toBe(zh.trigger) + 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(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('heading', { name: '成员3' })).toBeTruthy() }) - 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: 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() - 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] = summary(WORKER, true) }) + }) + 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(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) => { + const b = bench({ + ...(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}), + projections: {}, openState: 'loading', + }) + 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() + }) + + 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(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) => { + 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' as SessionId]: summary('unrelated' as SessionId, true) }, + projectionsBySession: { + ...projectionsBySession, + ['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 }]])) + }) + 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() + }) + + 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('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('团队持久记录无效: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 })) + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert').textContent).toBe('Error: navigation failed') + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByRole('dialog') - fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(document.activeElement).toBe(screen.getByRole('button', { name: /智能体团队/u })) + }) + + 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: {}, openState: 'loading' }) + rendered.rerender() expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) + openPanel() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('status').textContent).toBe(zh.loading) }) - 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('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() - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { + const b = bench() + const rendered = render() + const trigger = screen.getByRole('button', { name: /智能体团队/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,29 +348,218 @@ 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: /^worker/u }), { key: 'Enter' }) expect(screen.queryByRole('dialog')).not.toBeNull() - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { 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', async () => { - render(<>) - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + it.each([true, false])('clamps a long task description behind an expand toggle (ResizeObserver: %s)', async (resizeObserver) => { + if (!resizeObserver) vi.stubGlobal('ResizeObserver', undefined) + 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() + const rendered = render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + + fireEvent.mouseEnter(trigger) + await advance(149) + expect(screen.queryByRole('dialog')).toBeNull() + await advance(1) + const panel = screen.getByRole('dialog') + + fireEvent.mouseEnter(trigger) + 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(trigger) + fireEvent.mouseLeave(root, { relatedTarget: document.body }) + 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 = await screen.findByRole('dialog') - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.refresh }) }) + const panel = screen.getByRole('dialog') + fireEvent.mouseEnter(trigger) + 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(trigger) + 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(trigger) + 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 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(trigger) + 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(<>