From 806642b064251640e14bb8aebd47ce49e9e736a2 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Fri, 14 Aug 2026 13:10:19 +0800 Subject: [PATCH 1/9] feat(team): add experimental Agent Teams Web profile --- ...08-18-experimental-agent-teams-packages.md | 4 +- ...18-experimental-agent-teams-packages.zh.md | 4 +- .../2026-08-06-agent-teams-web.i18n.yaml | 6 + .../feature/2026-08-06-agent-teams-web.md | 39 ++ .../feature/2026-08-06-agent-teams-web.zh.md | 39 ++ apps/web/tests/agent-team-panel.e2e.ts | 89 +++ apps/web/tests/agent-team-panel.overlay.yml | 44 ++ apps/web/tests/scaffold.ts | 38 +- .../agent-team-panel/task.expected.md | 29 + apps/web/tsconfig.json | 1 + docs/subsystems/agent-team.md | 23 + docs/subsystems/agent-team.zh.md | 23 + knip.json | 11 + packages/experimental/README.md | 3 + packages/experimental/README.zh.md | 3 + .../agent-team-remotes/README.i18n.yaml | 6 + .../experimental/agent-team-remotes/README.md | 22 + .../agent-team-remotes/README.zh.md | 22 + .../agent-team-remotes/package.json | 62 ++ .../agent-team-remotes/src/client/index.ts | 19 + .../agent-team-remotes/src/index.ts | 4 + .../agent-team-remotes/src/invariant.ts | 23 + .../agent-team-remotes/tests/built-lib.e2e.ts | 95 +++ .../tests/invariant.spec.ts | 16 + .../agent-team-remotes/tsconfig.client.json | 20 + .../agent-team-remotes/tsconfig.host.json | 17 + .../agent-team-remotes/tsconfig.json | 11 + .../agent-team-remotes/tsdown.config.ts | 3 + .../agent-team-web-profile/README.i18n.yaml | 6 + .../agent-team-web-profile/README.md | 27 + .../agent-team-web-profile/README.zh.md | 27 + .../agent-team-web-profile/cordis.patch.yml | 9 + .../agent-team-web-profile/package.json | 53 ++ .../agent-team-web-profile/src/index.ts | 3 + .../agent-team-web-profile/src/invariant.ts | 23 + .../tests/profile.spec.ts | 36 ++ .../agent-team-web-profile/tsconfig.json | 12 + packages/experimental/agent-team/README.md | 5 + packages/experimental/agent-team/README.zh.md | 5 + packages/experimental/agent-team/package.json | 21 +- .../experimental/agent-team/src/client.ts | 3 + packages/experimental/agent-team/src/index.ts | 53 +- packages/experimental/agent-team/src/types.ts | 17 + .../agent-team/tests/team.spec.ts | 35 ++ .../experimental/agent-team/tsconfig.json | 1 + .../client-ui-agent-team/README.i18n.yaml | 6 + .../client-ui-agent-team/README.md | 25 + .../client-ui-agent-team/README.zh.md | 25 + .../client-ui-agent-team/package.json | 86 +++ .../src/client/TeamAction.module.css | 239 ++++++++ .../src/client/TeamAction.tsx | 433 ++++++++++++++ .../client-ui-agent-team/src/client/index.ts | 96 +++ .../src/client/locales.ts | 72 +++ .../client-ui-agent-team/src/css-modules.d.ts | 6 + .../client-ui-agent-team/src/index.ts | 4 + .../client-ui-agent-team/src/invariant.ts | 18 + .../tests/browser-plugin.client.spec.ts | 264 +++++++++ .../tests/team-action.client.spec.tsx | 549 ++++++++++++++++++ .../client-ui-agent-team/tsconfig.json | 21 + .../client-ui-agent-team/tsdown.config.ts | 3 + scripts/gen-cordis-catalog.ts | 2 + scripts/run-gates.spec.ts | 1 + scripts/run-gates.ts | 1 + .../verify-package-readme-model-experience.ts | 3 + tsconfig.base.json | 2 + tsconfig.client.json | 2 + tsconfig.host.json | 3 + vitest.config.ts | 2 + 68 files changed, 2864 insertions(+), 11 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-agent-teams-web.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md create mode 100644 apps/web/tests/agent-team-panel.e2e.ts create mode 100644 apps/web/tests/agent-team-panel.overlay.yml create mode 100644 apps/web/tests/snapshots/agent-team-panel/task.expected.md create mode 100644 packages/experimental/agent-team-remotes/README.i18n.yaml create mode 100644 packages/experimental/agent-team-remotes/README.md create mode 100644 packages/experimental/agent-team-remotes/README.zh.md create mode 100644 packages/experimental/agent-team-remotes/package.json create mode 100644 packages/experimental/agent-team-remotes/src/client/index.ts create mode 100644 packages/experimental/agent-team-remotes/src/index.ts create mode 100644 packages/experimental/agent-team-remotes/src/invariant.ts create mode 100644 packages/experimental/agent-team-remotes/tests/built-lib.e2e.ts create mode 100644 packages/experimental/agent-team-remotes/tests/invariant.spec.ts create mode 100644 packages/experimental/agent-team-remotes/tsconfig.client.json create mode 100644 packages/experimental/agent-team-remotes/tsconfig.host.json create mode 100644 packages/experimental/agent-team-remotes/tsconfig.json create mode 100644 packages/experimental/agent-team-remotes/tsdown.config.ts create mode 100644 packages/experimental/agent-team-web-profile/README.i18n.yaml create mode 100644 packages/experimental/agent-team-web-profile/README.md create mode 100644 packages/experimental/agent-team-web-profile/README.zh.md create mode 100644 packages/experimental/agent-team-web-profile/cordis.patch.yml create mode 100644 packages/experimental/agent-team-web-profile/package.json create mode 100644 packages/experimental/agent-team-web-profile/src/index.ts create mode 100644 packages/experimental/agent-team-web-profile/src/invariant.ts create mode 100644 packages/experimental/agent-team-web-profile/tests/profile.spec.ts create mode 100644 packages/experimental/agent-team-web-profile/tsconfig.json create mode 100644 packages/experimental/agent-team/src/client.ts create mode 100644 packages/experimental/client-ui-agent-team/README.i18n.yaml create mode 100644 packages/experimental/client-ui-agent-team/README.md create mode 100644 packages/experimental/client-ui-agent-team/README.zh.md create mode 100644 packages/experimental/client-ui-agent-team/package.json create mode 100644 packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css create mode 100644 packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx create mode 100644 packages/experimental/client-ui-agent-team/src/client/index.ts create mode 100644 packages/experimental/client-ui-agent-team/src/client/locales.ts create mode 100644 packages/experimental/client-ui-agent-team/src/css-modules.d.ts create mode 100644 packages/experimental/client-ui-agent-team/src/index.ts create mode 100644 packages/experimental/client-ui-agent-team/src/invariant.ts create mode 100644 packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts create mode 100644 packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx create mode 100644 packages/experimental/client-ui-agent-team/tsconfig.json create mode 100644 packages/experimental/client-ui-agent-team/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index 495922d57b..144484deee 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,13 +12,13 @@ An experimental directory without a current package previously imposed placement ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, and `packages/experimental/agent-team-profile` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/agent-team-remotes`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are private workspace packages. The [experimental package naming decision](2026-08-19-experimental-package-name-prefix.md) owns their npm names and promotion rename; this note owns their placement, release exclusion, and dependency isolation. The dsh pack and publish set and the local baseline publisher exclude every manifest below `packages/experimental/`. `release:dsh` still advances their manifest versions with the shared dsh version without creating release tags. Workspace constraints require each experimental package to set `private: true` and omit `publishConfig`. The same top-level check rejects `dependencies`, `optionalDependencies`, and `peerDependencies` from release packages, release apps, or the Python runtime to an experimental package. Experimental packages may depend on release packages and each other; tests may use them through `devDependencies`, and examples may load them explicitly. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The private Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows, disables the global continuable-child controls whose model-visible names overlap the Team tools, and leaves the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The private Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate private Web profile applies after `dsh-web-app` and the Host profile; it inserts a Team-only Client Remote assembly and the Team UI. Both layers leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. Profile startup resolves selected bundles before healing module fallbacks. The shared fallback retains the dsh installation's carrier-specific entries: symlinks under plain Node and ESM proxies in a packaged executable. Missing packages from selected bundle closures are linked under the current profile's own `node_modules`, while pnpm-managed profile entries remain authoritative. Closure discovery starts from each explicit external bundle's real package directory and traverses every listed root even when an earlier dependency has the same package name. It excludes dsh-owned profile projections from later discovery, so a projected dependency cannot feed back into its own closure. Link ownership compares canonical parent paths so junction-normalized targets remain removable. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 65aed57e22..01e9e94319 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,13 +12,13 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team` 与 `packages/experimental/agent-team-profile` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/agent-team-remotes`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是私有 workspace 包。[实验性包命名决策](2026-08-19-experimental-package-name-prefix.zh.md)负责其 npm 名和 promotion 重命名;本记录负责其目录归属、发布排除与依赖隔离。 dsh pack 与 publish 集合以及本地 baseline 发布器均排除 `packages/experimental/` 下的所有 manifest。`release:dsh` 仍会让这些 manifest 跟随 dsh 共享版本递增,但不会创建发布 tag。workspace 约束要求每个实验性包设置 `private: true` 并省略 `publishConfig`。同一个顶层检查会拒绝发布包、发布 app 或 Python runtime 通过 `dependencies`、`optionalDependencies` 或 `peerDependencies` 依赖实验性包。实验性包可以依赖发布包和其他实验性包;测试可以通过 `devDependencies` 使用它们,示例可以显式加载它们。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -私有 Agent Teams profile bundle 依赖 Team 包,并应用在 `dsh-base` 之后。它插入 Team 配置行,禁用模型可见名称与 Team 工具重叠的全局 continuable-child control,并保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 +私有 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立的私有 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team 专用 Client Remote assembly 与 Team UI。两个层都保持已发布 base、CLI、Web 与 Python runtime 的依赖图不变。 profile 启动会先解析所选 bundle,再修复模块 fallback。共享 fallback 保留 dsh 安装的载体专用条目:普通 Node 下使用 symlink,打包 executable 中使用 ESM proxy。仅由所选 bundle 闭包携带的缺失包会链接到当前 profile 自己的 `node_modules` 下,而 pnpm 管理的 profile 条目仍具有优先权。闭包发现从每个显式外部 bundle 的真实包目录开始;即使前一个依赖具有相同包名,也会遍历所有列出的根。后续发现会排除 dsh 所有的 profile projection,避免投影后的依赖重新进入自己的闭包。link ownership 通过规范化父路径比较,使 junction 规范化后的 target 仍可删除。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 diff --git a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml new file mode 100644 index 0000000000..8a70534909 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-06-agent-teams-web.md +2026-08-06-agent-teams-web.md: a8842bb647ca469fffcffe7f1d91eff42b6a403c +2026-08-06-agent-teams-web.zh.md: 55e8a65711fefe1ae2b9fcca6e0adb4d0358ecf6 diff --git a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md new file mode 100644 index 0000000000..a8842bb647 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md @@ -0,0 +1,39 @@ +# Agent Note: Experimental Agent Teams Web controls + +Status: implemented + +English | [中文](2026-08-06-agent-teams-web.zh.md) + +## Problem + +The durable Agent Teams runtime owns roster, mailbox, and task state but exposes only model tools and Host service methods. Web users need to inspect teammate activity, manage shared tasks with the same compare-and-set rules, and open a teammate conversation. Agent Teams is still experimental, so these capabilities must not add Team-specific contracts or dependencies to the stable API Proxy, Client runtime, Subagent UI, or Web bundle. + +## Decision + +`TeamService` directly contributes three Typert Remote methods: `teams/view`, `teams/createTask`, and `teams/updateTask`. The generated codecs use a browser-safe `@deepseek-ai/dsh-team/client` vocabulary. Views contain roster and current task state but omit pending mailbox content and deleted task tombstones. Task conflicts cross Remote as a closed business result so the browser can preserve `team-task-conflict`; transport and lookup failures remain ordinary `RemoteResult` failures. + +`@deepseek-ai/dsh-agent-team-remotes` is a private Client assembly that mounts the generated Team contribution through the stable `ctx.remote` service. `@deepseek-ai/dsh-client-ui-agent-team` consumes only `ctx.remote.teams`, Client Session navigation, locale, and slots. It displays roster status, model and diagnostics and supports task create, edit, dependency update, assignment, completion, reopen, and deletion. Every mutation sends the displayed revision. A conflict reloads the complete Team view and asks the user to review instead of retrying or overwriting automatically. Overlapping refreshes publish only the latest request for the selected Session, and a successful mutation invalidates older refresh snapshots. + +Teammate navigation uses the existing `{ parentSessionId, childSessionId, mode: 'continuable' }` Subagent address without a Team tag. The UI refreshes the direct-child catalog, rechecks the selected Session, and opens the addressed conversation. History and later human prompts follow the stable Subagent path; the Team mailbox remains reserved for Team peer delivery from Team tools. + +`@deepseek-ai/dsh-agent-team-web-profile` inserts the private Remote assembly and UI after the stable Web bundle. It is applied alongside the Host-side `@deepseek-ai/dsh-agent-team-profile`. Neither stable bundle contains disabled Team rows or dependencies. + +## Boundaries + +The Web UI has no mailbox timeline, worktree or Git controls, teammate creation, rename, deletion, interruption, or automatic merge behavior. It does not infer filesystem authority from task ownership or write scopes. A human continuation after teammate navigation is an ordinary addressed-child prompt, not a Team mailbox message. + +## Alternatives considered + +**Extend the legacy API Proxy Team RPC map.** Rejected because it would put an experimental domain in a stable wire package and duplicate the generated Remote vocabulary and validation. + +**Add Team metadata to the stable Subagent address and prompt routing.** Rejected because ordinary child navigation already identifies the conversation. A Team tag would couple stable Client and Subagent contracts to experimental mailbox policy. + +**Put disabled Team rows in the stable Web bundle.** Rejected because a disabled row still creates release dependencies and makes the experimental package part of shipped composition. + +## Testing + +Team Remote generation and Host build verify the typed methods. Client typechecking and browser component tests cover the mounted namespace, Lead routing, every task action, conflict reload, stale async results, navigation, disposal, and status or error presentation. A Web end-to-end test composes both experimental profile layers over the real Host Remote flow. + +## Consequences + +The Team service remains the only state machine, while Web is a typed projection and command adapter. The stable API Proxy, Client runtime, Subagent UI, and Web bundle remain Team-agnostic. Source-checkout users must add two ordered experimental profile layers to a Web profile, and promotion can move those packages without changing their npm names or generated namespace. diff --git a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md new file mode 100644 index 0000000000..55e8a65711 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.zh.md @@ -0,0 +1,39 @@ +# Agent Note:实验性 Agent Teams Web 控件 + +状态:已实现 + +[English](2026-08-06-agent-teams-web.md) | 中文 + +## 问题 + +持久 Agent Teams runtime 负责 roster、mailbox 与 task 状态,但只提供模型工具和 Host service method。Web 用户需要查看 teammate 活动、按同样的 compare-and-set 规则管理共享任务,并打开 teammate 会话。Agent Teams 仍处于实验阶段,因此这些能力不能向稳定 API Proxy、Client runtime、Subagent UI 或 Web bundle 增加 Team 专用 contract 或依赖。 + +## 决策 + +`TeamService` 直接提供三个 Typert Remote method:`teams/view`、`teams/createTask` 与 `teams/updateTask`。生成式 codec 使用浏览器安全的 `@deepseek-ai/dsh-team/client` vocabulary。View 包含 roster 与当前 task 状态,但不包含 pending mailbox 内容或已删除 task tombstone。Task conflict 通过封闭 business result 跨越 Remote,使浏览器保留 `team-task-conflict`;transport 与 lookup failure 仍是普通 `RemoteResult` failure。 + +`@deepseek-ai/dsh-agent-team-remotes` 是私有 Client assembly,通过稳定 `ctx.remote` service 挂载生成式 Team contribution。`@deepseek-ai/dsh-client-ui-agent-team` 只消费 `ctx.remote.teams`、Client Session navigation、locale 与 slot。它展示 roster status、model 与 diagnostics,并支持 task create、edit、dependency update、assignment、completion、reopen 与 deletion。每次 mutation 都发送当前显示的 revision。Conflict 会重新读取完整 Team view 并要求用户检查,不会自动重试或覆盖。重叠 refresh 只发布所选 Session 的最新请求,成功 mutation 会让更早的 refresh snapshot 失效。 + +Teammate navigation 使用既有 `{ parentSessionId, childSessionId, mode: 'continuable' }` Subagent address,不带 Team tag。UI 刷新直接 child catalog、再次检查所选 Session,然后打开 addressed conversation。History 与后续人类 prompt 使用稳定 Subagent 路径;Team mailbox 只用于 Team 工具发起的 Team peer delivery。 + +`@deepseek-ai/dsh-agent-team-web-profile` 在稳定 Web bundle 之后插入私有 Remote assembly 与 UI。它与 Host 侧 `@deepseek-ai/dsh-agent-team-profile` 一起应用。两个稳定 bundle 都不包含禁用的 Team row 或依赖。 + +## 边界 + +Web UI 不提供 mailbox timeline、worktree 或 Git control、teammate creation、rename、deletion、interrupt 或自动 merge。它不会从 task ownership 或 write scope 推断文件系统权限。导航到 teammate 后的人类 continuation 是普通 addressed-child prompt,不是 Team mailbox message。 + +## 考虑过的替代方案 + +**扩展 legacy API Proxy Team RPC map。** 拒绝,因为这会把实验性 domain 放入稳定 wire package,并重复生成式 Remote vocabulary 与 validation。 + +**向稳定 Subagent address 与 prompt routing 添加 Team metadata。** 拒绝,因为普通 child navigation 已经标识会话;Team tag 会让稳定 Client 与 Subagent contract 耦合实验性 mailbox policy。 + +**在稳定 Web bundle 中加入禁用 Team row。** 拒绝,因为禁用 row 仍会产生 release 依赖,并让实验性 package 成为随附 composition 的一部分。 + +## 测试 + +Team Remote 生成与 Host build 校验 typed method。Client typecheck 与浏览器 component test 覆盖挂载 namespace、Lead routing、所有 task action、conflict reload、陈旧 async result、navigation、dispose 与状态或错误呈现。Web 端到端测试在真实 Host Remote flow 上组合两个实验性 profile 层。 + +## 后果 + +Team service 仍是唯一状态机,Web 是 typed projection 与 command adapter。稳定 API Proxy、Client runtime、Subagent UI 和 Web bundle 保持 Team 无关。源码 checkout 用户必须向 Web profile 添加两个有序 experimental profile 层;promotion 可以移动这些 package,而无需修改 npm name 或生成式 namespace。 diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts new file mode 100644 index 0000000000..e36e921021 --- /dev/null +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -0,0 +1,89 @@ +// Keyless assembled-browser coverage for the private Agent Teams Web profiles +// over the real Host Typert Remote flow. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) +const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') +const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) +const INSTALL_ANCHORS = [ + fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)), + fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/package.json', import.meta.url)), +] +const MODE = webSnapshotMode() + +describe('web e2e: Agent Teams panel', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, extraInstallAnchors: INSTALL_ANCHORS }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Open the Agent Team controls.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'Ready.' }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + agent.session.append('step/end', { turn: 1, step: 1 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByText('Ready.').waitFor({ timeout: 10_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('loads the roster and creates one shared task through generated Remote', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) + const action = page.locator('[data-team-action]') + await action.getByRole('button', { name: /Agent Team/iu }).click() + await action.getByText('No shared tasks yet').waitFor() + await action.getByText('lead').waitFor() + + await action.getByRole('button', { name: 'New task' }).click() + await action.getByPlaceholder('Task subject').fill('Browser task') + await action.getByPlaceholder('Task description').fill('Created through the assembled browser') + await action.getByPlaceholder(/Write scopes/iu).fill('src/web') + await action.getByRole('button', { name: 'Save' }).click() + await action.getByText('Browser task').waitFor() + + const snapshot = await captureStableAria(page, '[data-team-action]', scaffold.workspaceCwd) + await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md']) + }) +}) diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml new file mode 100644 index 0000000000..73c7b949c5 --- /dev/null +++ b/apps/web/tests/agent-team-panel.overlay.yml @@ -0,0 +1,44 @@ +# Equivalent to the two explicit Agent Teams profile layers over the scaffold's +# stable base and Web bundles. +- id: tool-subagent-control + disabled: true + +- id: tool-subagent-list-agents + disabled: true + +- id: tool-subagent-report + disabled: true + +- id: tool-subagent + config: + provider: spawn + toolName: subagent + backgroundMode: one-shot + +- id: tool-subagent-fork + config: + provider: fork + toolName: subagent_fork + backgroundMode: one-shot + +- insert: + - id: team + name: '@deepseek-ai/dsh-team' + config: + maxMembers: 8 + maxTasks: 256 + maxPendingMessagesPerMember: 64 + maxMessageBytes: 65536 + disposalTimeoutMs: 5000 + + - id: tool-team + name: '@deepseek-ai/dsh-tool-team' + config: + freshProvider: spawn + forkProvider: fork + + - id: agent-team-remotes + name: '@deepseek-ai/dsh-agent-team-remotes' + + - id: ui-agent-team + name: '@deepseek-ai/dsh-client-ui-agent-team' diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 58e4e249b7..6e89812da7 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -54,6 +54,7 @@ import { composeEntries, healProfilesModuleFallback, loadOverlayPatches, + type Profile, } from '@deepseek-ai/dsh-app-boot' import { dshHomePath } from '@deepseek-ai/dsh-home-paths' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -237,6 +238,11 @@ export interface LaunchOptions { * ordering. */ extraOverlayPath?: string + /** + * Additional source-checkout package manifests whose dependency closures + * supply private profile layers named by {@link extraOverlayPath}. + */ + extraInstallAnchors?: string[] /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -569,11 +575,35 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise/profiles. - await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, home: harnessHome }) const profileDir = join(harnessHome, 'profiles', 'scaffold') + const extraLayers: Profile['layers'] = await Promise.all((options.extraInstallAnchors ?? []).map(async (anchor) => { + const manifest = JSON.parse(await readFile(anchor, 'utf8')) as { name?: unknown } + if (typeof manifest.name !== 'string' || manifest.name === '') { + throw new Error(`web scaffold extra install anchor has no package name: ${anchor}`) + } + const packageDir = dirname(anchor) + return { + packageName: manifest.name, + packageDir, + patchPath: join(packageDir, 'cordis.patch.yml'), + patches: [], + } + })) + // Mirror the production launcher: the shared installation closure keeps + // its carrier-specific fallback, while private bundle dependencies stay + // isolated to this synthetic scaffold profile. + await healProfilesModuleFallback({ + installAnchor: INSTALL_ANCHOR, + home: harnessHome, + profile: { + name: 'scaffold', + dir: profileDir, + layers: extraLayers, + patchPath: join(profileDir, 'cordis.patch.yml'), + patches: [], + patchReload: 'startup', + }, + }) await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md new file mode 100644 index 0000000000..42543b8027 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -0,0 +1,29 @@ +- button "Agent Team" [expanded]: + - img + - text: Agent Team +- dialog "Agent Team": + - strong: Agent Team + - button "Refresh Team": + - img + - button "Close": + - img + - heading "Members" [level=3] + - 'button "lead idle · Model: deepseek-v4-flash" [disabled]' + - heading "Shared tasks" [level=3] + - button "New task": + - img + - text: New task + - article: + - strong: Browser task + - text: Pending + - paragraph: Created through the assembled browser + - text: "task-1 Ready Write scopes: src/web Owner" + - combobox "Owner": + - option "Unowned" [selected] + - option "lead" + - button "Edit": + - img + - text: Edit + - button "Delete": + - img + - text: Delete diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a104388946..0388704e61 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -82,6 +82,7 @@ "tests/shipped-composition.e2e.ts", "tests/schedule-after.e2e.ts", "tests/feedback-command.e2e.ts", + "tests/agent-team-panel.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 8704bff307..84becd182c 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -105,6 +105,13 @@ membership(agent: Agent): TeamMembership */ listMembers(agent: Agent): TeamMemberView[] +/** + * Read the current roster and non-deleted task board for a browser client. + * @param agent - exact live Team member used as the authority credential. + * @returns detached current roster and task views. + */ +@Remote('view') view(agent: Agent): TeamView + /** * Create one named, continuable direct child of the Team Lead. * @param caller - exact live Lead Agent. @@ -129,6 +136,14 @@ async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise +/** + * Create one shared task for a browser client. + * @param agent - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ +@Remote('createTask') createTaskForClient(agent: Agent, request: CreateTeamTaskRequest): Promise + /** * Return one task, including a deleted tombstone. * @param caller - exact live Team member reading the task. @@ -152,6 +167,14 @@ listTasks(caller: Agent): TeamTaskView[] */ async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise +/** + * Apply one task mutation for a browser client while preserving CAS conflicts. + * @param agent - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed task or a browser-safe Team rejection. + */ +@Remote('updateTask') async updateTaskForClient(agent: Agent, request: UpdateTeamTaskRequest): Promise + /** * Wait for the next Team-domain or member-status change. * @param caller - exact live Team member waiting for activity. diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index dfa55743e5..4fb5e4fb7b 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -105,6 +105,13 @@ membership(agent: Agent): TeamMembership */ listMembers(agent: Agent): TeamMemberView[] +/** + * Read the current roster and non-deleted task board for a browser client. + * @param agent - exact live Team member used as the authority credential. + * @returns detached current roster and task views. + */ +@Remote('view') view(agent: Agent): TeamView + /** * Create one named, continuable direct child of the Team Lead. * @param caller - exact live Lead Agent. @@ -129,6 +136,14 @@ async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise +/** + * Create one shared task for a browser client. + * @param agent - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ +@Remote('createTask') createTaskForClient(agent: Agent, request: CreateTeamTaskRequest): Promise + /** * Return one task, including a deleted tombstone. * @param caller - exact live Team member reading the task. @@ -152,6 +167,14 @@ listTasks(caller: Agent): TeamTaskView[] */ async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise +/** + * Apply one task mutation for a browser client while preserving CAS conflicts. + * @param agent - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed task or a browser-safe Team rejection. + */ +@Remote('updateTask') async updateTaskForClient(agent: Agent, request: UpdateTeamTaskRequest): Promise + /** * Wait for the next Team-domain or member-status change. * @param caller - exact live Team member waiting for activity. diff --git a/knip.json b/knip.json index 09e6327745..a989ed37c2 100644 --- a/knip.json +++ b/knip.json @@ -756,6 +756,17 @@ "@deepseek-ai/.+" ] }, + "packages/experimental/agent-team-remotes": { + "entry": [ + "tests/**/*.e2e.ts" + ] + }, + "packages/experimental/agent-team-web-profile": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-team-remotes", + "@deepseek-ai/dsh-client-ui-agent-team" + ] + }, "packages/bundle/web-app": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/packages/experimental/README.md b/packages/experimental/README.md index ad658da843..10a5017567 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -26,6 +26,9 @@ The experimental group contains prototype capabilities that are not part of any |---|---|---| | [`agent-team-profile`](agent-team-profile/README.md) | Explicit source-checkout profile layer for Agent Teams | — | | [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` | +| [`agent-team-remotes`](agent-team-remotes/README.md) | Client assembly for the generated Agent Teams Remote contribution | `ctx.remote.agentTeams` | +| [`agent-team-web-profile`](agent-team-web-profile/README.md) | Explicit source-checkout Web layer for Agent Teams | — | +| [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — | | [`tool-agent-team`](tool-agent-team/README.md) | Ten tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` | | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key | | [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index a8b777b40e..dbd1605996 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -26,6 +26,9 @@ kind: "package-group" |---|---|---| | [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 的显式源码 checkout profile 层 | — | | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` | +| [`agent-team-remotes`](agent-team-remotes/README.zh.md) | 生成式 Agent Teams Remote contribution 的 Client assembly | `ctx.remote.agentTeams` | +| [`agent-team-web-profile`](agent-team-web-profile/README.zh.md) | Agent Teams 的显式源码 checkout Web 层 | — | +| [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — | | [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的十个工具 | 按作用域注册工具到 `ctx.tools` | | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩 VFS 镜像 | 库与 CLI,不使用 ctx key | | [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key | diff --git a/packages/experimental/agent-team-remotes/README.i18n.yaml b/packages/experimental/agent-team-remotes/README.i18n.yaml new file mode 100644 index 0000000000..42db67354d --- /dev/null +++ b/packages/experimental/agent-team-remotes/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-remotes/README.md +README.md: be1d28c5ef53b932a9553a716488e465fd2ad8ce +README.zh.md: ee4e0734e1296424b428b8ecb184a5e8b06b194a diff --git a/packages/experimental/agent-team-remotes/README.md b/packages/experimental/agent-team-remotes/README.md new file mode 100644 index 0000000000..be1d28c5ef --- /dev/null +++ b/packages/experimental/agent-team-remotes/README.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-agent-team-remotes + +English | [中文](README.zh.md) + +Private Client assembly for the Agent Teams Typert Remote contribution. Its Client entry imports the generated `@deepseek-ai/dsh-team/remote` runtime value, mounts it through the stable `ctx.remote.$mount()` service, and re-exports the declaration merge that adds `ctx.remote.teams`. + +The contribution exposes `teams/view`, `teams/createTask`, and `teams/updateTask`. The generated codecs validate arguments and results, while the Team service remains the only owner of roster and task state. This package contains no Host resolver or transport logic; `@deepseek-ai/dsh-api-remotes` supplies the stable Remote service and Agent identity policy. + +The root export is inert because this package mounts only in a Client environment. [`@deepseek-ai/dsh-agent-team-web-profile`](../agent-team-web-profile/README.md) inserts it before the Team UI so the namespace exists when the UI activates. + +## Model Experience + +None, as this Client assembly only mounts typed Remote methods and registers no model-facing input. + +#### KV Cache effect + +No direct effect; invoked Team methods and their model-facing consumers own any later effect. + +## Known Limitations and Deferred Work + +- **Fixed contribution set** — adding a Team Remote method requires regenerating the Team artifacts and rebuilding this explicit assembly. +- **Source-checkout only** — this private package is excluded from official releases. diff --git a/packages/experimental/agent-team-remotes/README.zh.md b/packages/experimental/agent-team-remotes/README.zh.md new file mode 100644 index 0000000000..ee4e0734e1 --- /dev/null +++ b/packages/experimental/agent-team-remotes/README.zh.md @@ -0,0 +1,22 @@ +# @deepseek-ai/dsh-agent-team-remotes + +[English](README.md) | 中文 + +Agent Teams Typert Remote contribution 的私有 Client assembly。它的 Client entry 导入生成式 `@deepseek-ai/dsh-team/remote` runtime value,通过稳定 `ctx.remote.$mount()` service 挂载,并重新导出为 `ctx.remote.teams` 增加类型的 declaration merge。 + +该 contribution 提供 `teams/view`、`teams/createTask` 与 `teams/updateTask`。生成式 codec 校验参数和结果,Team service 仍是 roster 与 task 状态的唯一 owner。本包不包含 Host resolver 或 transport 逻辑;`@deepseek-ai/dsh-api-remotes` 提供稳定 Remote service 与 Agent identity policy。 + +Root export 不执行行为,因为本包只在 Client 环境挂载。[`@deepseek-ai/dsh-agent-team-web-profile`](../agent-team-web-profile/README.md) 会先于 Team UI 插入本包,确保 UI 激活时 namespace 已存在。 + +## 模型体验 + +无直接影响,因为该 Client assembly 只挂载 typed Remote method,不注册面向模型的输入。 + +#### KV Cache 影响 + +无直接影响;被调用的 Team method 及其面向模型的 consumer 负责后续任何影响。 + +## 已知限制与暂缓事项 + +- **固定 contribution 集合**:增加 Team Remote method 时,需要重新生成 Team artifact 并重建这个显式 assembly。 +- **仅限源码 checkout**:正式发布会排除这个私有包。 diff --git a/packages/experimental/agent-team-remotes/package.json b/packages/experimental/agent-team-remotes/package.json new file mode 100644 index 0000000000..13979d5780 --- /dev/null +++ b/packages/experimental/agent-team-remotes/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-agent-team-remotes", + "description": "Private Client assembly for the Agent Teams Typert Remote contribution", + "version": "0.1.0-rc.7", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/agent-team-remotes" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/experimental/agent-team-remotes/src/client/index.ts b/packages/experimental/agent-team-remotes/src/client/index.ts new file mode 100644 index 0000000000..9a8b5edf52 --- /dev/null +++ b/packages/experimental/agent-team-remotes/src/client/index.ts @@ -0,0 +1,19 @@ +/** Client assembly for the generated Agent Teams Remote contribution. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import teamsRemote from '@deepseek-ai/dsh-team/remote' + +export type {} from '@deepseek-ai/dsh-team/remote' + +/** Required service: the typed Client Remote contribution mount. */ +export const inject = ['remote'] + +/** + * Mount the Agent Teams Remote namespace selected by the experimental Web profile. + * @param ctx - Client Cordis root carrying the Remote service. + * @returns disposer after the contribution is ready. + */ +export function apply(ctx: Context): Promise<() => Promise> { + return ctx.remote.$mount(teamsRemote) +} diff --git a/packages/experimental/agent-team-remotes/src/index.ts b/packages/experimental/agent-team-remotes/src/index.ts new file mode 100644 index 0000000000..5539b6adc3 --- /dev/null +++ b/packages/experimental/agent-team-remotes/src/index.ts @@ -0,0 +1,4 @@ +/** Pure Host half for the private Agent Teams Client Remote assembly. */ + +/** Host plugin body; the generated contribution mounts only in Client environments. */ +export function apply(): void {} diff --git a/packages/experimental/agent-team-remotes/src/invariant.ts b/packages/experimental/agent-team-remotes/src/invariant.ts new file mode 100644 index 0000000000..8522554074 --- /dev/null +++ b/packages/experimental/agent-team-remotes/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion for the Agent Teams Client Remote assembly. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-team-remotes' + +/** Cordis companion plugin name. */ +export const name = 'agent-team-remotes-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: Client Remote validates each request and result with +// the generated Team codecs, while the Team service owns mutable state. +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/agent-team-remotes/tests/built-lib.e2e.ts b/packages/experimental/agent-team-remotes/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..43b3877eda --- /dev/null +++ b/packages/experimental/agent-team-remotes/tests/built-lib.e2e.ts @@ -0,0 +1,95 @@ +/** Plain-Node smoke for the generated Agent Teams Client Remote assembly. */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +const packageDir = fileURLToPath(new URL('..', import.meta.url)) +const root = resolve(packageDir, '../../..') +const artifact = (path: string): string => join(root, path) +const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href + +const requiredArtifacts = [ + 'packages/experimental/agent-team-remotes/lib/client.js', + 'packages/experimental/agent-team-remotes/lib/index.js', + 'packages/experimental/team/lib/typert.remote-client.js', +].every(path => existsSync(artifact(path))) + +describe.skipIf(!requiredArtifacts)('Agent Teams Remote built LIB assembly', () => { + it('mounts exactly the generated Team contribution and keeps the Host half inert', async () => { + const urls = { + client: artifactUrl('packages/experimental/agent-team-remotes/lib/client.js'), + host: artifactUrl('packages/experimental/agent-team-remotes/lib/index.js'), + } + const script = ` + const handoffs = new Map() + globalThis.window = { + __ModuleLoader__: { + load(handoff) { handoffs.set(handoff.id, handoff) }, + }, + } + const host = await import(${JSON.stringify(urls.host)}) + host.apply() + await import(${JSON.stringify(urls.client)}) + const handoff = handoffs.get('@deepseek-ai/dsh-agent-team-remotes') + if (handoff === undefined) throw new Error('missing Agent Teams Remote Client handoff') + const plugin = handoff.factory(specifier => { + throw new Error('unexpected Client external ' + specifier) + }) + let mounted + const dispose = () => {} + const result = await plugin.apply({ + remote: { + $mount(contribution) { + mounted = contribution + return Promise.resolve(dispose) + }, + }, + }) + console.log(JSON.stringify({ + inject: plugin.inject, + sameDisposer: result === dispose, + methods: mounted?.descriptors.map(descriptor => descriptor.id), + })) + ` + + const result = await runPlainNode(script) + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) + const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { + inject: string[] + sameDisposer: boolean + methods: string[] + } + expect(output).toEqual({ + inject: ['remote'], + sameDisposer: true, + methods: [ + '@deepseek-ai/dsh-team#teams/createTask', + '@deepseek-ai/dsh-team#teams/updateTask', + '@deepseek-ai/dsh-team#teams/view', + ], + }) + }) +}) + +function runPlainNode(script: string): Promise<{ + readonly exitCode: number | null + readonly stdout: string + readonly stderr: string +}> { + return new Promise((resolveRun) => { + execFile(process.execPath, ['--input-type=module', '-e', script], { + cwd: packageDir, + encoding: 'utf8', + timeout: 30_000, + }, (error, stdout, stderr) => { + resolveRun({ + exitCode: error === null ? 0 : typeof error.code === 'number' ? error.code : null, + stdout, + stderr, + }) + }) + }) +} diff --git a/packages/experimental/agent-team-remotes/tests/invariant.spec.ts b/packages/experimental/agent-team-remotes/tests/invariant.spec.ts new file mode 100644 index 0000000000..c8a8ea33ad --- /dev/null +++ b/packages/experimental/agent-team-remotes/tests/invariant.spec.ts @@ -0,0 +1,16 @@ +/** Package invariant registration for the Agent Teams Remote assembly. */ + +import { describe, expect, it, vi } from 'vitest' +import * as invariant from '../src/invariant.ts' + +describe('Agent Teams Remote invariant', () => { + it('reserves package ownership with an empty invariant installer', async () => { + const register = vi.fn().mockReturnValue(() => {}) + const ctx = { invariants: { register } } as never + + const dispose = await invariant.apply(ctx) + expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-agent-team-remotes', expect.any(Function)) + expect(() => { (register.mock.calls[0]![1] as () => void)() }).not.toThrow() + expect(dispose).toBeTypeOf('function') + }) +}) diff --git a/packages/experimental/agent-team-remotes/tsconfig.client.json b/packages/experimental/agent-team-remotes/tsconfig.client.json new file mode 100644 index 0000000000..34f7f86636 --- /dev/null +++ b/packages/experimental/agent-team-remotes/tsconfig.client.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": ["src/client/index.ts"], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../team" + } + ] +} diff --git a/packages/experimental/agent-team-remotes/tsconfig.host.json b/packages/experimental/agent-team-remotes/tsconfig.host.json new file mode 100644 index 0000000000..2f18caea89 --- /dev/null +++ b/packages/experimental/agent-team-remotes/tsconfig.host.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": ["src/index.ts", "src/invariant.ts"], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/experimental/agent-team-remotes/tsconfig.json b/packages/experimental/agent-team-remotes/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/experimental/agent-team-remotes/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/experimental/agent-team-remotes/tsdown.config.ts b/packages/experimental/agent-team-remotes/tsdown.config.ts new file mode 100644 index 0000000000..24d7f8d797 --- /dev/null +++ b/packages/experimental/agent-team-remotes/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-agent-team-remotes', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/packages/experimental/agent-team-web-profile/README.i18n.yaml new file mode 100644 index 0000000000..33f7358d3c --- /dev/null +++ b/packages/experimental/agent-team-web-profile/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-web-profile/README.md +README.md: cf893ce84f30ae07834b2ff1d4272b037547cb3e +README.zh.md: e4ecafb020a84b37f2babd731abc4d09f35e66e7 diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md new file mode 100644 index 0000000000..cf893ce84f --- /dev/null +++ b/packages/experimental/agent-team-web-profile/README.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-agent-team-web-profile + +English | [中文](README.zh.md) + +Private Web profile layer for Agent Teams. Apply it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-agent-team-profile`](../agent-team-profile/README.md). The patch inserts the experimental Client Remote assembly followed by the Team conversation-header UI; it does not modify the stable Web bundle. + +From a source checkout, add both Agent Teams layers to an initialized Web profile: + +```sh +pnpm dsh plugin --profile web add ./packages/experimental/agent-team-profile +pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile +``` + +The Host profile supplies the Team domain and model tools. This Web layer supplies only the generated Client Remote namespace and browser presentation. Removing either experimental bundle leaves the stable base and Web composition unchanged. + +## Model Experience + +Indirectly, through the Host-side Agent Teams profile selected alongside this Web layer. + +#### KV Cache effect + +No direct effect; the Host-side Team tools own prompt and schema changes. + +## Known Limitations and Deferred Work + +- **Ordered composition** — `dsh-base`, `dsh-web-app`, `dsh-agent-team-profile`, and this package must remain in that order. +- **Source-checkout only** — official CLI, Web, npm, and Python release payloads exclude this private package. diff --git a/packages/experimental/agent-team-web-profile/README.zh.md b/packages/experimental/agent-team-web-profile/README.zh.md new file mode 100644 index 0000000000..e4ecafb020 --- /dev/null +++ b/packages/experimental/agent-team-web-profile/README.zh.md @@ -0,0 +1,27 @@ +# @deepseek-ai/dsh-agent-team-web-profile + +[English](README.md) | 中文 + +Agent Teams 的私有 Web profile 层。应当在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-agent-team-profile`](../agent-team-profile/README.md) 之后应用。本 patch 先插入实验性 Client Remote assembly,再插入 Team 会话页头 UI;它不修改稳定 Web bundle。 + +在源码 checkout 中,将两个 Agent Teams 层添加到已初始化的 Web profile: + +```sh +pnpm dsh plugin --profile web add ./packages/experimental/agent-team-profile +pnpm dsh plugin --profile web add ./packages/experimental/agent-team-web-profile +``` + +Host profile 提供 Team domain 与模型工具。本 Web 层只提供生成式 Client Remote namespace 与浏览器呈现。移除任一实验性 bundle 后,稳定 base 与 Web composition 保持不变。 + +## 模型体验 + +间接通过与该 Web 层同时选择的 Host 侧 Agent Teams profile 产生影响。 + +#### KV Cache 影响 + +无直接影响;Host 侧 Team 工具负责 prompt 与 schema 变化。 + +## 已知限制与暂缓事项 + +- **有序 composition**:`dsh-base`、`dsh-web-app`、`dsh-agent-team-profile` 与本包必须保持该顺序。 +- **仅限源码 checkout**:正式 CLI、Web、npm 与 Python 发布产物会排除这个私有包。 diff --git a/packages/experimental/agent-team-web-profile/cordis.patch.yml b/packages/experimental/agent-team-web-profile/cordis.patch.yml new file mode 100644 index 0000000000..14984af370 --- /dev/null +++ b/packages/experimental/agent-team-web-profile/cordis.patch.yml @@ -0,0 +1,9 @@ +# Private Agent Teams Web layer. Apply after dsh-web-app and the host-side +# dsh-agent-team-profile so the browser mounts only when both seams are present. + +- insert: + - id: agent-team-remotes + name: '@deepseek-ai/dsh-agent-team-remotes' + + - id: ui-agent-team + name: '@deepseek-ai/dsh-client-ui-agent-team' diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json new file mode 100644 index 0000000000..0fec9d1577 --- /dev/null +++ b/packages/experimental/agent-team-web-profile/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-agent-team-web-profile", + "description": "Private Web profile layer for Agent Teams Remote and UI plugins", + "version": "0.1.0-rc.7", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/agent-team-web-profile" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-agent-team-remotes": "workspace:^", + "@deepseek-ai/dsh-client-ui-agent-team": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "js-yaml": "^4.2.0" + } +} diff --git a/packages/experimental/agent-team-web-profile/src/index.ts b/packages/experimental/agent-team-web-profile/src/index.ts new file mode 100644 index 0000000000..2ca6d6012f --- /dev/null +++ b/packages/experimental/agent-team-web-profile/src/index.ts @@ -0,0 +1,3 @@ +/** Private Web profile layer for the Agent Teams Client plugins. */ + +export {} diff --git a/packages/experimental/agent-team-web-profile/src/invariant.ts b/packages/experimental/agent-team-web-profile/src/invariant.ts new file mode 100644 index 0000000000..42531dd04d --- /dev/null +++ b/packages/experimental/agent-team-web-profile/src/invariant.ts @@ -0,0 +1,23 @@ +/** Package-owned invariant companion for the Agent Teams Web profile. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-agent-team-web-profile' + +/** Cordis companion plugin name. */ +export const name = 'agent-team-web-profile-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: the package carries only a static profile patch. The +// Remote assembly and Team UI own their activation requirements. +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts new file mode 100644 index 0000000000..f8efa92c14 --- /dev/null +++ b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts @@ -0,0 +1,36 @@ +/** The experimental Web bundle must carry one parseable Team Client layer. */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('Agent Teams Web profile bundle', () => { + it('declares a private parseable layer with the Remote assembly before the UI', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + private?: boolean + publishConfig?: unknown + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.private).toBe(true) + expect(manifest.publishConfig).toBeUndefined() + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.dependencies).toEqual({ + '@deepseek-ai/dsh-agent-team-remotes': 'workspace:^', + '@deepseek-ai/dsh-client-ui-agent-team': 'workspace:^', + }) + + const parsed = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) as { insert?: { id?: string; name?: string }[] }[] + expect(parsed.flatMap(patch => patch.insert ?? [])).toEqual([ + { id: 'agent-team-remotes', name: '@deepseek-ai/dsh-agent-team-remotes' }, + { id: 'ui-agent-team', name: '@deepseek-ai/dsh-client-ui-agent-team' }, + ]) + }) +}) diff --git a/packages/experimental/agent-team-web-profile/tsconfig.json b/packages/experimental/agent-team-web-profile/tsconfig.json new file mode 100644 index 0000000000..440bba72ae --- /dev/null +++ b/packages/experimental/agent-team-web-profile/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../runtime-diagnostics/invariants" } + ] +} diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index ca088a4cf3..7f5d99d2ef 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -164,6 +164,11 @@ Read these pages when the package-level contract is not enough. They move from t ----- + +### Client Remote surface + +`TeamService` contributes the generated `teams/view`, `teams/createTask`, and `teams/updateTask` Typert Remote methods. `./client` exports only browser-safe Team request and view types; `./typert` and `./remote` are generated Host and Client artifacts. The view omits mailbox contents and deleted task tombstones. Task conflicts cross Remote as an explicit business result so a Client can reload instead of losing the Team error code inside a generic carrier failure. + ## Model Experience ### Peer messages diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index ec5f7e1f46..0fc1c9f099 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -164,6 +164,11 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch ----- + +### Client Remote 界面 + +`TeamService` 提供生成式 `teams/view`、`teams/createTask` 与 `teams/updateTask` Typert Remote method。`./client` 只导出浏览器安全的 Team request 与 view type;`./typert` 和 `./remote` 是生成的 Host 与 Client artifact。View 不包含 mailbox 内容或已删除 task tombstone。Task conflict 通过显式 business result 跨越 Remote,使 Client 能重新读取状态,而不会把 Team error code 丢失在通用 carrier failure 中。 + ## 模型体验 ### Peer 消息 diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index da61dd441e..e0410967ec 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -20,13 +20,30 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.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" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts" + "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" ], "license": "MIT", "dependencies": { @@ -41,6 +58,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -58,6 +76,7 @@ "@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/cordis": "workspace:^" } } diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts new file mode 100644 index 0000000000..c76bab501d --- /dev/null +++ b/packages/experimental/agent-team/src/client.ts @@ -0,0 +1,3 @@ +/** Client-safe Agent Teams request, result, and view vocabulary. */ + +export type * from './types.ts' diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index c66e2ce3f6..981212eea3 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -1,9 +1,10 @@ /** Agent Teams service façade over roster, mailbox, task, and runtime lifecycle owners. */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } 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' @@ -22,6 +23,8 @@ import type { SpawnTeammateResult, TeamMemberView, TeamTaskView, + TeamTaskMutationResult, + TeamView, TeamWaitResult, UpdateTeamTaskRequest, } from './types.ts' @@ -53,7 +56,7 @@ function positiveLimit(name: string, value: number): number { } /** Agent Teams service backed by the exact live Lead Session log. */ -export class TeamService extends Service { +export class TeamService extends TypertRemoteService { static inject = ['agents', 'sessions', 'sessionPersistence', 'subagents'] static Config: z = z.object({ @@ -132,6 +135,19 @@ export class TeamService extends Service { return this.roster.list(this.roster.membership(agent)) } + /** + * Read the current roster and non-deleted task board for a browser client. + * @param agent - exact live Team member used as the authority credential. + * @returns detached current roster and task views. + */ + @Remote('view') + view(agent: Agent): TeamView { + return { + members: this.listMembers(agent), + tasks: this.listTasks(agent), + } + } + /** * Create one named, continuable direct child of the Team Lead. * @param caller - exact live Lead Agent. @@ -162,6 +178,17 @@ export class TeamService extends Service { return await this.tasks.create(this.roster.membership(caller), request) } + /** + * Create one shared task for a browser client. + * @param agent - exact live Team member creating the task. + * @param request - task text, blockers, and advisory write scopes. + * @returns the revision-one task view. + */ + @Remote('createTask') + createTaskForClient(agent: Agent, request: CreateTeamTaskRequest): Promise { + return this.createTask(agent, request) + } + /** * Return one task, including a deleted tombstone. * @param caller - exact live Team member reading the task. @@ -191,6 +218,28 @@ export class TeamService extends Service { return await this.tasks.update(caller, this.roster.membership(caller), request) } + /** + * Apply one task mutation for a browser client while preserving CAS conflicts. + * @param agent - exact live Team member authorizing the mutation. + * @param request - task identity, expected revision, action, and action fields. + * @returns the committed task or a browser-safe Team rejection. + */ + @Remote('updateTask') + async updateTaskForClient(agent: Agent, request: UpdateTeamTaskRequest): Promise { + try { + return { ok: true, value: await this.updateTask(agent, request) } + } catch (error) { + if (!(error instanceof TeamError)) throw error + return { + ok: false, + error: { + code: error.code === 'TEAM_TASK_STALE_REVISION' ? 'team-task-conflict' : 'team-rejected', + message: error.message, + }, + } + } + } + /** * Wait for the next Team-domain or member-status change. * @param caller - exact live Team member waiting for activity. diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index cee2b1064a..bca6c49d38 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -96,6 +96,23 @@ export interface TeamTaskView { readonly writeScopeWarnings: string[] } +/** Browser-safe point-in-time roster and task-board view. */ +export interface TeamView { + readonly members: TeamMemberView[] + readonly tasks: TeamTaskView[] +} + +/** Browser mutation result preserving stale-revision recovery across Remote. */ +export type TeamTaskMutationResult = + | { readonly ok: true; readonly value: TeamTaskView } + | { + readonly ok: false + readonly error: { + readonly code: 'team-task-conflict' | 'team-rejected' + readonly message: string + } + } + /** One peer message retained until its target Session records it. */ export interface TeamMessageSnapshot { readonly id: TeamMessageId diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 9b606a3de3..ae594227b7 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -512,6 +512,41 @@ describe('Team identity and provisioning', () => { }) describe('Team shared task DAG', () => { + it('projects browser views and preserves task mutation failures across Remote', async () => { + const { ctx, lead } = await setup([]) + expect(ctx.teams.view(lead)).toEqual({ + members: [expect.objectContaining({ id: lead.id, name: 'lead', role: 'lead' })], + tasks: [], + }) + + const task = await ctx.teams.createTaskForClient(lead, { + subject: 'browser task', + description: 'created through the Remote face', + }) + await expect(ctx.teams.updateTaskForClient(lead, { + taskId: task.id, + expectedRevision: task.revision, + action: 'claim', + })).resolves.toMatchObject({ ok: true, value: { status: 'in_progress' } }) + await expect(ctx.teams.updateTaskForClient(lead, { + taskId: task.id, + expectedRevision: task.revision, + action: 'delete', + })).resolves.toMatchObject({ ok: false, error: { code: 'team-task-conflict' } }) + await expect(ctx.teams.updateTaskForClient(lead, { + taskId: TeamTaskId('task-999'), + expectedRevision: 1, + action: 'delete', + })).resolves.toMatchObject({ ok: false, error: { code: 'team-rejected' } }) + + vi.spyOn(ctx.teams, 'updateTask').mockRejectedValueOnce(new Error('unexpected mutation failure')) + await expect(ctx.teams.updateTaskForClient(lead, { + taskId: task.id, + expectedRevision: 2, + action: 'delete', + })).rejects.toThrow('unexpected mutation failure') + }) + it('fails loudly when the durable numeric task id space is exhausted', async () => { const { ctx, lead } = await setup([]) const id = TeamTaskId(`task-${Number.MAX_SAFE_INTEGER}`) diff --git a/packages/experimental/agent-team/tsconfig.json b/packages/experimental/agent-team/tsconfig.json index d0defa8242..ecfdf8e6e3 100644 --- a/packages/experimental/agent-team/tsconfig.json +++ b/packages/experimental/agent-team/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../../core/session" }, { "path": "../../core/agent" }, { "path": "../../subagent/subagent" }, + { "path": "../../typert/protocol" }, { "path": "../../session/session-persistence" }, { "path": "../../runtime-diagnostics/invariants" } ] diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml new file mode 100644 index 0000000000..d716c98671 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 18352135f3883e8ecda94bc3c1d7c900cdf5d7ad +README.zh.md: abfbbf6a6238f16274db42b7e7295f21d5406edd diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md new file mode 100644 index 0000000000..18352135f3 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/README.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-client-ui-agent-team + +English | [中文](README.zh.md) + +Private Web Agent Teams presentation. It contributes one conversation-header action containing the current roster and shared task board. The browser calls the generated `ctx.remote.teams` namespace supplied by [`@deepseek-ai/dsh-agent-team-remotes`](../agent-team-remotes/README.md); it does not extend the stable API Proxy or store authoritative Team state. + +Opening the panel calls `teams/view`. Roster rows show durable names, runtime status, model, and diagnostics. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific field to that address. + +The task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Humans can create, edit, assign or unassign, complete, reopen, and delete tasks through `teams/createTask` and `teams/updateTask`. Every mutation sends the displayed revision. A `team-task-conflict` result reloads the Team view and displays a stale-state notice instead of retrying or overwriting. Editing task text or scopes and changing dependencies remain two sequential compare-and-set mutations because the Team service exposes them as separate actions. + +The root export is inert on the Host. The Client export owns locale and slot registrations, and Cordis disposes both with the plugin fiber. Install the package through [`@deepseek-ai/dsh-agent-team-web-profile`](../agent-team-web-profile/README.md) after the stable Web bundle and the Host-side Agent Teams profile. + +## Model Experience + +None, as this browser projection and task control surface registers no model-facing input. + +#### KV Cache effect + +No direct effect; the Team tools and ordinary conversation submission own any later model-visible use. + +## Known Limitations and Deferred Work + +- **Snapshot refresh** — the panel refreshes on open, explicit refresh, and mutations; it has no live event subscription or mailbox timeline. +- **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. +- **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md new file mode 100644 index 0000000000..abfbbf6a62 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -0,0 +1,25 @@ +# @deepseek-ai/dsh-client-ui-agent-team + +[English](README.md) | 中文 + +私有 Web Agent Teams 呈现包。它向会话页头提供一个包含当前 roster 与共享任务板的 action。浏览器调用由 [`@deepseek-ai/dsh-agent-team-remotes`](../agent-team-remotes/README.md) 提供的生成式 `ctx.remote.teams` namespace;它不扩展稳定 API Proxy,也不存储权威 Team 状态。 + +打开 panel 会调用 `teams/view`。Roster row 展示持久 name、运行时 status、model 与 diagnostics。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类 prompt 继续使用稳定 addressed-subagent 会话路径;本包不会向该 address 添加 Team 专用字段。 + +任务板展示 task identity、owner、blocker、readiness、提示性 write scope 与重叠 warning。人类可以通过 `teams/createTask` 与 `teams/updateTask` 创建、编辑、分配或取消分配、完成、重开和删除任务。每次 mutation 都发送当前显示的 revision。收到 `team-task-conflict` 结果后,UI 会重新读取 Team view 并显示状态陈旧提示,不会自动重试或覆盖。Team service 将任务文本或 scope 编辑与 dependency 修改公开为两个独立 action,因此两者仍使用两个连续的 compare-and-set mutation。 + +Root export 在 Host 上不执行行为。Client export 负责 locale 与 slot 注册,Cordis 会随 plugin fiber dispose 两者。在稳定 Web bundle 与 Host 侧 Agent Teams profile 之后,通过 [`@deepseek-ai/dsh-agent-team-web-profile`](../agent-team-web-profile/README.md) 安装本包。 + +## 模型体验 + +无直接影响,因为该浏览器 projection 与任务控制界面不注册面向模型的输入。 + +#### KV Cache 影响 + +无直接影响;Team 工具与普通会话提交负责后续任何模型可见用途。 + +## 已知限制与暂缓事项 + +- **Snapshot refresh**:panel 会在打开、显式 refresh 与 mutation 后刷新;它没有实时 event subscription 或 mailbox timeline。 +- **普通 child continuation**:导航后发送的人类消息使用稳定 addressed-subagent prompt 路径,而不是 Team peer mailbox。 +- **没有 lifecycle 或 workspace control**:panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json new file mode 100644 index 0000000000..e514a9e6af --- /dev/null +++ b/packages/experimental/client-ui-agent-team/package.json @@ -0,0 +1,86 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-agent-team", + "description": "Web Agent Teams roster, task board, and teammate navigation", + "version": "0.1.0-rc.7", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-agent-team" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-agent-team-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent-team-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent-team-remotes": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-team": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@testing-library/react": "^16.1.0", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^", + "react-dom": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} 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 new file mode 100644 index 0000000000..8de88e2e33 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -0,0 +1,239 @@ +.root { + position: relative; +} + +.trigger, +.iconButton, +.smallButton, +.taskActions button, +.formActions button { + 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; + font-size: 12px; +} + +.trigger:hover, +.iconButton:hover, +.smallButton:hover, +.taskActions button:hover, +.formActions button:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.count { + min-width: 16px; + border-radius: 8px; + background: var(--dsw-alias-fill-l2); + font-variant-numeric: tabular-nums; + text-align: center; +} + +.panel { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 110; + box-sizing: border-box; + width: min(560px, calc(100vw - 32px)); + max-height: min(680px, calc(100vh - 120px)); + padding: 10px; + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); +} + +.toolbar, +.sectionTitle, +.taskTitle, +.taskActions, +.formActions { + 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; +} + +.panel h3 { + margin: 12px 0 6px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + font-weight: 500; +} + +.roster { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.member { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + padding: 8px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + text-align: left; + cursor: pointer; +} + +.member:disabled { + cursor: default; +} + +.memberText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.memberText small, +.meta { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.diagnostic, +.error, +.warning { + color: var(--dsw-alias-state-error-primary); +} + +.smallButton { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; + padding: 4px 7px; + font-size: 11px; +} + +.tasks { + display: flex; + flex-direction: column; + gap: 7px; +} + +.task, +.form { + padding: 9px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 9px; + background: var(--dsw-alias-bg-base); +} + +.taskTitle span { + margin-left: auto; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.task p { + margin: 5px 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + white-space: pre-wrap; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; +} + +.taskActions { + flex-wrap: wrap; + margin-top: 8px; +} + +.taskActions label { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.taskActions select, +.form input, +.form textarea { + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-primary); + font: inherit; +} + +.taskActions button, +.formActions button { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 4px 6px; + font-size: 11px; +} + +.taskActions button:disabled, +.formActions button:disabled { + opacity: 0.45; + cursor: default; +} + +.form { + display: grid; + gap: 6px; + margin-bottom: 7px; +} + +.form input, +.form textarea { + width: 100%; + padding: 6px 8px; + font-size: 12px; +} + +.form textarea { + min-height: 58px; + resize: vertical; +} + +.notice, +.error { + padding: 9px; + font-size: 12px; +} diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx new file mode 100644 index 0000000000..38e0ac6684 --- /dev/null +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -0,0 +1,433 @@ +import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + TeamMemberView as TeamRosterMember, + TeamTaskAction, + TeamTaskId, + TeamTaskView as TeamTask, + TeamView, +} from '@deepseek-ai/dsh-team/client' +import { + IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16, + IconRefreshOutline14, IconTrashOutline16, IconUserOutline16, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { TeamKey } from './locales.ts' +import css from './TeamAction.module.css' + +/** Settled Team UI action result, including the stale-revision discriminator. */ +export type TeamActionResult = + | { ok: true; value: T } + | { ok: false; error: string; conflict: boolean } + +/** Business actions injected by the browser plugin. */ +export interface TeamActionInjected { + load: (sessionId: SessionId) => Promise> + createTask: (sessionId: SessionId, input: { + subject: string + description: string + blockedBy: TeamTaskId[] + writeScopes: string[] + }) => Promise> + updateTask: (sessionId: SessionId, input: { + taskId: TeamTaskId + expectedRevision: number + action: TeamTaskAction + subject?: string + description?: string + blockedBy?: TeamTaskId[] + writeScopes?: string[] + owner?: string + }) => Promise> + openTeammate: (sessionId: SessionId, member: TeamRosterMember) => Promise +} + +/** Full props of the Team conversation-header action. */ +export type TeamActionProps = + PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale<'team'> + +interface Draft { + subject: string + description: string + blockers: string + scopes: string +} + +const EMPTY_DRAFT: Draft = { subject: '', description: '', blockers: '', scopes: '' } + +function items(value: string): string[] { + return [...new Set(value.split(',').map(item => item.trim()).filter(Boolean))] +} + +function taskIds(value: string): TeamTaskId[] { + return items(value) as TeamTaskId[] +} + +function statusKey(status: TeamTask['status']): TeamKey { + switch (status) { + case 'pending': return 'status.pending' + case 'in_progress': return 'status.in_progress' + case 'completed': return 'status.completed' + /* v8 ignore next -- Team views omit deleted task tombstones. */ + case 'deleted': return 'status.completed' + } +} + +function loadedView(current: TeamView | null, update: (view: TeamView) => TeamView): TeamView | null { + /* v8 ignore next -- task controls and forms render only after a Team view exists. */ + if (current === null) return null + return update(current) +} + +function replaceTask(tasks: TeamTask[], task: TeamTask): TeamTask[] { + const index = tasks.findIndex(candidate => candidate.id === task.id) + /* v8 ignore next -- mutation responses preserve the requested task id. */ + if (index < 0) return tasks + return tasks.with(index, task) +} + +/** Render the live Team roster and compare-and-set task board. */ +export function TeamAction({ + sessionId, load, createTask, updateTask, openTeammate, t, +}: TeamActionProps) { + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [view, setView] = useState(null) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [createDraft, setCreateDraft] = useState(EMPTY_DRAFT) + const [editing, setEditing] = useState(null) + const [editDraft, setEditDraft] = useState(EMPTY_DRAFT) + const [pendingTask, setPendingTask] = useState(null) + const sessionRef = useRef(sessionId) + const refreshGeneration = useRef(0) + sessionRef.current = sessionId + + useEffect(() => { + refreshGeneration.current += 1 + setOpen(false) + setLoading(false) + setView(null) + setError(null) + setCreating(false) + setCreateDraft(EMPTY_DRAFT) + setEditing(null) + setEditDraft(EMPTY_DRAFT) + setPendingTask(null) + }, [sessionId]) + + 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(result.error) + } + }, [load, sessionId]) + + const invalidateRefresh = useCallback((): void => { + refreshGeneration.current += 1 + setLoading(false) + }, []) + + const settleTask = useCallback(async ( + taskId: string, + operation: Promise>, + ): Promise => { + const requestedSession = sessionId + setPendingTask(taskId) + const result = await operation + if (sessionRef.current !== requestedSession) return undefined + setPendingTask(null) + if (!result.ok) { + if (result.conflict) { + await refresh() + if (sessionRef.current !== requestedSession) return undefined + setError(t('conflict')) + } else { + setError(result.error) + } + return undefined + } + invalidateRefresh() + setError(null) + setView(current => loadedView(current, loaded => ({ + ...loaded, + tasks: result.value.status === 'deleted' + ? loaded.tasks.filter(task => task.id !== result.value.id) + : replaceTask(loaded.tasks, result.value), + }))) + return result.value + }, [invalidateRefresh, refresh, sessionId, t]) + + const submitCreate = async (): Promise => { + const subject = createDraft.subject.trim() + const description = createDraft.description.trim() + /* v8 ignore next -- TaskForm disables Save while either normalized field is empty. */ + if (subject === '' || description === '') return + setPendingTask('create') + const requestedSession = sessionId + const result = await createTask(requestedSession, { + subject, + description, + blockedBy: taskIds(createDraft.blockers), + writeScopes: items(createDraft.scopes), + }) + if (sessionRef.current !== requestedSession) return + setPendingTask(null) + if (!result.ok) { + setError(result.error) + return + } + invalidateRefresh() + setView(current => loadedView(current, loaded => ({ + ...loaded, + tasks: [...loaded.tasks, result.value], + }))) + setCreateDraft(EMPTY_DRAFT) + setCreating(false) + setError(null) + } + + const startEdit = (task: TeamTask): void => { + setEditing(task.id) + setEditDraft({ + subject: task.subject, + description: task.description, + blockers: task.blockedBy.join(', '), + scopes: task.writeScopes.join(', '), + }) + } + + const submitEdit = async (task: TeamTask): Promise => { + const requestedSession = sessionId + const edited = await settleTask(task.id, updateTask(requestedSession, { + taskId: task.id, + expectedRevision: task.revision, + action: 'edit', + subject: editDraft.subject.trim(), + description: editDraft.description.trim(), + writeScopes: items(editDraft.scopes), + })) + if (edited === undefined) return + const blockedBy = taskIds(editDraft.blockers) + if (blockedBy.length === edited.blockedBy.length + && blockedBy.every((blocker, index) => blocker === edited.blockedBy[index])) { + setEditing(null) + return + } + setPendingTask(task.id) + const dependencyResult = await updateTask(requestedSession, { + taskId: task.id, + expectedRevision: edited.revision, + action: 'set_dependencies', + blockedBy, + }) + if (sessionRef.current !== requestedSession) return + setPendingTask(null) + if (!dependencyResult.ok) { + if (dependencyResult.conflict) { + await refresh() + if (sessionRef.current !== requestedSession) return + } + setError(dependencyResult.conflict ? t('conflict') : dependencyResult.error) + return + } + invalidateRefresh() + setView(current => loadedView(current, loaded => ({ + ...loaded, + tasks: replaceTask(loaded.tasks, dependencyResult.value), + }))) + setEditing(null) + } + + const teammates = view?.members.filter(member => member.role === 'teammate') ?? [] + const assignable = view?.members.filter(member => member.status !== 'failed' && member.status !== 'provisioning') ?? [] + + return ( +
+ + {open && ( +
+
+ {t('trigger')} + + + +
+ {error !== null &&
{error}
} + {loading && view === null &&
{t('loading')}
} + {view !== null && ( + <> +
+

{t('roster')}

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

{t('tasks')}

+ +
+ {creating && ( + { void submitCreate() }} + onCancel={() => { setCreating(false) }} + t={t} + /> + )} + {view.tasks.length === 0 && !creating &&
{t('empty')}
} +
+ {view.tasks.map(task => editing === task.id + ? ( + { void submitEdit(task) }} + onCancel={() => { setEditing(null) }} + t={t} + /> + ) + : ( +
+
+ {task.subject} + {t(statusKey(task.status))} +
+

{task.description}

+
+ {task.id} + {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})} +
+
+ + + {task.status === 'in_progress' && ( + + )} + {task.status === 'completed' && ( + + )} + +
+
+ ))} +
+
+ + )} +
+ )} +
+ ) +} + +interface TaskFormProps { + draft: Draft + setDraft: (draft: Draft) => void + pending: boolean + onSave: () => void + onCancel: () => void + t: TeamActionProps['t'] +} + +function TaskForm({ draft, setDraft, pending, onSave, onCancel, t }: TaskFormProps) { + const field = (key: keyof Draft, value: string): void => { setDraft({ ...draft, [key]: value }) } + return ( +
+ ) => { field('subject', event.target.value) }} /> +