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 index fca3ee6a79..3761232606 100644 --- a/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md +++ b/.agents/notes/implemented/feature/2026-08-06-agent-teams-web.md @@ -10,13 +10,13 @@ The durable Agent Teams runtime owns roster, mailbox, and task state but exposes ## Decision -The private `ctx.teams` service owns generated `teams/view`, `teams/createTask`, and `teams/updateTask` Remote methods beside its domain operations. The Team package owns the browser-safe view and mutation-result types. 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. +The private `ctx.agentTeams` service owns generated `agentTeams/view`, `agentTeams/createTask`, and `agentTeams/updateTask` Remote methods beside its domain operations. The Team package owns the browser-safe view and mutation-result types. Views contain roster and current task state but omit pending mailbox content and deleted task tombstones. Create and update rejections cross Remote as closed business results; stale update revisions preserve `team-task-conflict`, while other Team rejections preserve `team-rejected`. Unexpected failures remain ordinary `RemoteResult` failures. -`@deepseek-ai/dsh-client-ui-agent-team` mounts the `@deepseek-ai/dsh-team/remote` contribution through the stable `ctx.remote` service, then consumes the generated `ctx.remote.teams` methods without an additional Client result wrapper. 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 only after the reload succeeds; a reload failure remains visible. Overlapping refreshes publish only the latest request for the selected Session, and a successful mutation invalidates older refresh snapshots. +`@deepseek-ai/dsh-experimental-client-ui-agent-team` mounts the `@deepseek-ai/dsh-experimental-agent-team/remote` contribution through the stable `ctx.remote` service, then consumes the generated `ctx.remote.agentTeams` methods without an additional Client result wrapper. It displays roster status, model and diagnostics and supports task create, edit, dependency update, assignment, completion, reopen, and deletion. Every update sends the displayed revision. Each create or update owns an independent pending token, invalidates older refreshes before starting, and reloads the complete Team view after success. A conflict asks the user to review only after its reload succeeds; a reload failure remains visible. Overlapping refreshes publish only the latest request for the selected Session. 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 only the UI after the stable Web bundle. It is applied alongside the Host-side `@deepseek-ai/dsh-agent-team-profile`, which already inserts `ctx.teams` and the model tools. Neither stable bundle contains disabled Team rows or dependencies. +`@deepseek-ai/dsh-experimental-agent-team-web-profile` inserts only the UI after the stable Web bundle. It is applied alongside the Host-side `@deepseek-ai/dsh-experimental-agent-team-profile`, which already inserts `ctx.agentTeams` and the model tools. Neither stable bundle contains disabled Team rows or dependencies. ## Boundaries @@ -26,7 +26,7 @@ The Web UI has no mailbox timeline, worktree or Git controls, teammate creation, **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. -**Introduce a separate browser Remote service.** Rejected because the methods have no state, lifecycle, or policy owner distinct from `ctx.teams`; a second Cordis service would duplicate Team injection and require another package for the same Typert namespace. +**Introduce a separate browser Remote service.** Rejected because the methods have no state, lifecycle, or policy owner distinct from `ctx.agentTeams`; a second Cordis service would duplicate Team injection and require another package for the same Typert namespace. **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. @@ -34,8 +34,8 @@ The Web UI has no mailbox timeline, worktree or Git controls, teammate creation, ## Testing -Team-service unit tests, generation, and a plain-Node built-artifact smoke verify the direct Remote methods, error mapping, and exported descriptors. Client typechecking and browser component tests cover the mounted namespace, Lead routing, raw generated results, every task action, successful and failed conflict reloads, 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. +Team-service unit tests, generation, and a plain-Node built-artifact smoke verify the direct Remote methods, error mapping, and exported descriptors. Client typechecking and browser component tests cover the mounted namespace, Lead routing, raw generated results, every task action, independent pending operations, complete-board reloads, successful and failed conflict reloads, stale async results, navigation, disposal, and status or error presentation. A Web end-to-end test asserts that its overlay equals both shipped experimental profile layers, then exercises the real Host Remote flow. ## Consequences -The Team service is the single Cordis owner for both domain state and the Remote operations that expose selected Team values. 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. +The Team service is the single Cordis owner for both domain state and the Remote operations that expose selected Team values. 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. Promotion renames the experimental npm packages but does not require a new 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 index 5f3e0333ae..3825a5a74d 100644 --- 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 @@ -10,13 +10,13 @@ ## 决策 -私有 `ctx.teams` service 除 domain operation 外,还直接负责生成式 `teams/view`、`teams/createTask` 与 `teams/updateTask` Remote method。Team package 负责浏览器安全的 view 与 mutation-result type。View 包含 roster 与当前 task 状态,但不包含 pending mailbox 内容或已删除 task tombstone。Task conflict 通过封闭 business result 跨越 Remote,使浏览器保留 `team-task-conflict`;transport 与 lookup failure 仍是普通 `RemoteResult` failure。 +私有 `ctx.agentTeams` service 除 domain operation 外,还直接负责生成式 `agentTeams/view`、`agentTeams/createTask` 与 `agentTeams/updateTask` Remote method。Team package 负责浏览器安全的 view 与 mutation-result type。View 包含 roster 与当前 task 状态,但不包含 pending mailbox 内容或已删除 task tombstone。Create 与 update rejection 通过封闭 business result 跨越 Remote;过期的 update revision 保留为 `team-task-conflict`,其他 Team rejection 保留为 `team-rejected`。意外 failure 仍是普通 `RemoteResult` failure。 -`@deepseek-ai/dsh-client-ui-agent-team` 通过稳定 `ctx.remote` service 挂载 `@deepseek-ai/dsh-team/remote` contribution,随后直接消费生成式 `ctx.remote.teams` method,不增加 Client result 包装层。它展示 roster status、model 与 diagnostics,并支持 task create、edit、dependency update、assignment、completion、reopen 与 deletion。每次 mutation 都发送当前显示的 revision。Conflict 仅在重新读取完整 Team view 成功后要求用户检查;如果重新读取失败,则保留该错误。重叠 refresh 只发布所选 Session 的最新请求,成功 mutation 会让更早的 refresh snapshot 失效。 +`@deepseek-ai/dsh-experimental-client-ui-agent-team` 通过稳定 `ctx.remote` service 挂载 `@deepseek-ai/dsh-experimental-agent-team/remote` contribution,随后直接消费生成式 `ctx.remote.agentTeams` method,不增加 Client result 包装层。它展示 roster status、model 与 diagnostics,并支持 task create、edit、dependency update、assignment、completion、reopen 与 deletion。每次 update 都发送当前显示的 revision。每个 create 或 update 都独立持有 pending token,在开始前使更早的 refresh 失效,并在成功后重新读取完整 Team view。Conflict 仅在其 reload 成功后要求用户检查;如果重新读取失败,则保留该错误。重叠 refresh 只发布所选 Session 的最新请求。 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 之后只插入 UI。它与 Host 侧 `@deepseek-ai/dsh-agent-team-profile` 一起应用,后者已经插入 `ctx.teams` 与模型工具。两个稳定 bundle 都不包含禁用的 Team row 或依赖。 +`@deepseek-ai/dsh-experimental-agent-team-web-profile` 在稳定 Web bundle 之后只插入 UI。它与 Host 侧 `@deepseek-ai/dsh-experimental-agent-team-profile` 一起应用,后者已经插入 `ctx.agentTeams` 与模型工具。两个稳定 bundle 都不包含禁用的 Team row 或依赖。 ## 边界 @@ -26,7 +26,7 @@ Web UI 不提供 mailbox timeline、worktree 或 Git control、teammate creation **扩展 legacy API Proxy Team RPC map。** 拒绝,因为这会把实验性 domain 放入稳定 wire package,并重复生成式 Remote vocabulary 与 validation。 -**引入独立的浏览器 Remote service。** 拒绝,因为这些 method 没有区别于 `ctx.teams` 的状态、lifecycle 或 policy owner;第二个 Cordis service 会重复 Team injection,并要求另一个 package 提供同一个 Typert namespace。 +**引入独立的浏览器 Remote service。** 拒绝,因为这些 method 没有区别于 `ctx.agentTeams` 的状态、lifecycle 或 policy owner;第二个 Cordis service 会重复 Team injection,并要求另一个 package 提供同一个 Typert namespace。 **向稳定 Subagent address 与 prompt routing 添加 Team metadata。** 拒绝,因为普通 child navigation 已经标识会话;Team tag 会让稳定 Client 与 Subagent contract 耦合实验性 mailbox policy。 @@ -34,8 +34,8 @@ Web UI 不提供 mailbox timeline、worktree 或 Git control、teammate creation ## 测试 -Team service 单元测试、生成流程与 plain-Node built-artifact smoke 校验直接 Remote method、error mapping 与导出 descriptor。Client typecheck 与浏览器 component test 覆盖挂载 namespace、Lead routing、原始生成式 result、所有 task action、成功及失败的 conflict reload、陈旧 async result、navigation、dispose 与状态或错误呈现。Web 端到端测试在真实 Host Remote flow 上组合两个实验性 profile 层。 +Team service 单元测试、生成流程与 plain-Node built-artifact smoke 校验直接 Remote method、error mapping 与导出 descriptor。Client typecheck 与浏览器 component test 覆盖挂载 namespace、Lead routing、原始生成式 result、所有 task action、独立 pending operation、完整 task board reload、成功及失败的 conflict reload、陈旧 async result、navigation、dispose 与状态或错误呈现。Web 端到端测试先断言 overlay 等于两个随附实验性 profile 层,再运行真实 Host Remote flow。 ## 后果 -Team service 是 domain state 与公开选定 Team value 的 Remote operation 的唯一 Cordis owner。稳定 API Proxy、Client runtime、Subagent UI 和 Web bundle 保持 Team 无关。源码 checkout 用户必须向 Web profile 添加两个有序 experimental profile 层;promotion 可以移动这些 package,而无需修改 npm name 或生成式 namespace。 +Team service 是 domain state 与公开选定 Team value 的 Remote operation 的唯一 Cordis owner。稳定 API Proxy、Client runtime、Subagent UI 和 Web bundle 保持 Team 无关。源码 checkout 用户必须向 Web profile 添加两个有序 experimental profile 层。Promotion 会重命名实验性 npm package,但不要求新的生成式 namespace。 diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index e36e921021..2461be9420 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -2,9 +2,12 @@ // over the real Host Typert Remote flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' +import { readFileSync } from 'node:fs' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import * as yaml from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -15,12 +18,29 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) +const HOST_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) +const WEB_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-web-profile/cordis.patch.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() +function profileEntries(path: string): unknown[] { + const parsed = yaml.load(readFileSync(path, 'utf8'), { schema: entryListSchema }) + if (!Array.isArray(parsed)) throw new Error(`profile layer at ${path} must be a list`) + return parsed +} + +describe('Agent Teams panel overlay', () => { + it('matches the shipped Host and Web profile layers', () => { + expect(profileEntries(OVERLAY)).toEqual([ + ...profileEntries(HOST_PATCH), + ...profileEntries(WEB_PATCH), + ]) + }) +}) + describe('web e2e: Agent Teams panel', () => { let scaffold: WebScaffold let browser: Browser diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml index b08727e4cc..46d8637c1b 100644 --- a/apps/web/tests/agent-team-panel.overlay.yml +++ b/apps/web/tests/agent-team-panel.overlay.yml @@ -37,5 +37,6 @@ freshProvider: spawn forkProvider: fork +- insert: - id: ui-agent-team name: '@deepseek-ai/dsh-client-ui-agent-team' diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md index 696be051ab..04cd4de4fa 100644 --- a/packages/experimental/agent-team-web-profile/README.md +++ b/packages/experimental/agent-team-web-profile/README.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-agent-team-web-profile +# @deepseek-ai/dsh-experimental-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 Team conversation-header UI; it does not modify the stable Web bundle. +Private Web profile layer for Agent Teams. Apply it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md). The patch inserts 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: @@ -23,5 +23,6 @@ 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. +- **Ordered composition** — `dsh-base`, `dsh-web-app`, `dsh-experimental-agent-team-profile`, and this package must remain in that order. +- **Preset-scoped legacy controls** — stable Web presets still mount continuable Subagent controls inside the preset scope. Top-level Host profile overrides do not replace those scoped registrations, so the Team roster and legacy child controls can both appear until Web has a Team-aware preset. - **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 index 07470c5fed..bb9c5ec583 100644 --- a/packages/experimental/agent-team-web-profile/README.zh.md +++ b/packages/experimental/agent-team-web-profile/README.zh.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-agent-team-web-profile +# @deepseek-ai/dsh-experimental-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 插入 Team 会话页头 UI;它不修改稳定 Web bundle。 +Agent Teams 的私有 Web profile 层。应当在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) 之后应用。本 patch 插入 Team 会话页头 UI;它不修改稳定 Web bundle。 在源码 checkout 中,将两个 Agent Teams 层添加到已初始化的 Web profile: @@ -23,5 +23,6 @@ Host profile 提供 Team domain、生成式 Remote method 与模型工具。本 ## 已知限制与暂缓事项 -- **有序 composition**:`dsh-base`、`dsh-web-app`、`dsh-agent-team-profile` 与本包必须保持该顺序。 +- **有序 composition**:`dsh-base`、`dsh-web-app`、`dsh-experimental-agent-team-profile` 与本包必须保持该顺序。 +- **Preset scope 内的 legacy control**:稳定 Web preset 仍会在 preset scope 中挂载 continuable Subagent control。顶层 Host profile override 不会替换这些 scoped registration,因此在 Web 提供 Team-aware preset 之前,Team roster 与 legacy child control 可能同时出现。 - **仅限源码 checkout**:正式 CLI、Web、npm 与 Python 发布产物会排除这个私有包。 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 3f42646fc9..bac73d873e 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -167,7 +167,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser Remote -`TeamService` owns the generated `teams/view`, `teams/createTask`, and `teams/updateTask` Remote methods beside the roster, mailbox, task, and lifecycle operations. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` re-exports the request, view, and task-mutation result types that are safe in a browser compilation face. Typert retains transport failures in its outer `RemoteResult`; task conflicts and other Team rejections remain explicit domain results inside a successful transport response. +`TeamService` owns the generated `agentTeams/view`, `agentTeams/createTask`, and `agentTeams/updateTask` Remote methods beside the roster, mailbox, task, and lifecycle operations. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` re-exports the request, view, and task-mutation result types that are safe in a browser compilation face. Typert retains transport failures in its outer `RemoteResult`; create and update rejections remain explicit domain results inside a successful transport response, with stale update revisions distinguished as task conflicts. ## Model Experience diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 33f7b6c3a5..2981e611a4 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -167,7 +167,7 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch ### 浏览器 Remote -`TeamService` 除了 roster、mailbox、task 与 lifecycle operation,还直接负责生成式 `teams/view`、`teams/createTask` 与 `teams/updateTask` Remote method。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 则重新导出可在浏览器 compilation face 中安全使用的 request、view 与 task mutation result type。Typert 在外层 `RemoteResult` 中保留 transport failure;task conflict 与其他 Team rejection 则作为 transport 成功响应中的显式 domain result。 +`TeamService` 除了 roster、mailbox、task 与 lifecycle operation,还直接负责生成式 `agentTeams/view`、`agentTeams/createTask` 与 `agentTeams/updateTask` Remote method。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 则重新导出可在浏览器 compilation face 中安全使用的 request、view 与 task mutation result type。Typert 在外层 `RemoteResult` 中保留 transport failure;create 与 update rejection 则作为 transport 成功响应中的显式 domain result,其中过期的 update revision 会区分为 task conflict。 ## 模型体验 diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 4faf9b37ac..a207cd2da4 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -242,11 +242,11 @@ export class TeamService extends TypertRemoteService { * Create one shared task through the generated Remote API. * @param agent - exact live Team member creating the task. * @param request - task text, blockers, and advisory write scopes. - * @returns the revision-one task view. + * @returns the revision-one task or a typed Team rejection. */ @Remote('createTask') - remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise { - return this.createTask(agent, request) + remoteCreateTask(agent: Agent, request: CreateTeamTaskRequest): Promise { + return this.taskMutationResult(this.createTask(agent, request)) } /** @@ -256,9 +256,14 @@ export class TeamService extends TypertRemoteService { * @returns the committed task or a typed Team rejection. */ @Remote('updateTask') - async remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise { + remoteUpdateTask(agent: Agent, request: UpdateTeamTaskRequest): Promise { + return this.taskMutationResult(this.updateTask(agent, request)) + } + + /** Preserve Team task rejections while allowing unexpected failures to reject the Remote call. */ + private async taskMutationResult(operation: Promise): Promise { try { - return { ok: true, value: await this.updateTask(agent, request) } + return { ok: true, value: await operation } } catch (error) { if (!(error instanceof TeamError)) throw error return { diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 4b0b5125b4..d5c471d665 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -840,19 +840,22 @@ describe('Team shared task DAG', () => { describe('Team Remote API', () => { it('exports Team views and task mutations from the owning service', async () => { const { ctx, lead } = await setup([]) - expect(ctx.teams.typertRemote).toMatchObject({ serviceKey: 'teams', namespace: 'teams' }) - expect(ctx.teams.remoteView(lead)).toEqual({ + expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) + expect(ctx.agentTeams.remoteView(lead)).toEqual({ members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'idle' })], tasks: [], }) - const created = await ctx.teams.remoteCreateTask(lead, { + const createdResult = await ctx.agentTeams.remoteCreateTask(lead, { subject: 'Remote task', description: 'Created through the generated API', blockedBy: [], - writeScopes: ['packages/experimental/team'], + writeScopes: ['packages/experimental/agent-team'], }) - await expect(ctx.teams.remoteUpdateTask(lead, { + expect(createdResult).toMatchObject({ ok: true, value: { revision: 1 } }) + if (!createdResult.ok) throw new Error('Remote task creation did not succeed') + const created = createdResult.value + await expect(ctx.agentTeams.remoteUpdateTask(lead, { taskId: created.id, expectedRevision: created.revision, action: 'claim', @@ -860,26 +863,38 @@ describe('Team Remote API', () => { ok: true, value: { id: created.id, revision: 2, ownerName: 'lead' }, }) - expect(ctx.teams.remoteView(lead).tasks).toHaveLength(1) + expect(ctx.agentTeams.remoteView(lead).tasks).toHaveLength(1) }) it('preserves Team task rejections and propagates unexpected failures', async () => { const { ctx, lead } = await setup([]) + const createRequest = { + subject: 'Remote task', description: 'Rejected task', blockedBy: [], writeScopes: [], + } const request = { taskId: TeamTaskId('task-1'), expectedRevision: 1, action: 'delete' as const } - vi.spyOn(ctx.teams, 'updateTask') + vi.spyOn(ctx.agentTeams, 'createTask') + .mockRejectedValueOnce(new TeamError('invalid task', 'TEAM_TASK_INVALID')) + .mockRejectedValueOnce(new Error('unexpected creation failure')) + vi.spyOn(ctx.agentTeams, 'updateTask') .mockRejectedValueOnce(new TeamError('stale', 'TEAM_TASK_STALE_REVISION')) .mockRejectedValueOnce(new TeamError('denied', 'TEAM_TASK_FORBIDDEN')) .mockRejectedValueOnce(new Error('unexpected mutation failure')) - await expect(ctx.teams.remoteUpdateTask(lead, request)).resolves.toEqual({ + await expect(ctx.agentTeams.remoteCreateTask(lead, createRequest)).resolves.toEqual({ + ok: false, + error: { code: 'team-rejected', message: 'invalid task' }, + }) + await expect(ctx.agentTeams.remoteCreateTask(lead, createRequest)) + .rejects.toThrow('unexpected creation failure') + await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).resolves.toEqual({ ok: false, error: { code: 'team-task-conflict', message: 'stale' }, }) - await expect(ctx.teams.remoteUpdateTask(lead, request)).resolves.toEqual({ + await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).resolves.toEqual({ ok: false, error: { code: 'team-rejected', message: 'denied' }, }) - await expect(ctx.teams.remoteUpdateTask(lead, request)).rejects.toThrow('unexpected mutation failure') + await expect(ctx.agentTeams.remoteUpdateTask(lead, request)).rejects.toThrow('unexpected mutation failure') }) }) diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 7d26e38b7b..8e4170c702 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -1,14 +1,14 @@ -# @deepseek-ai/dsh-client-ui-agent-team +# @deepseek-ai/dsh-experimental-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 Client plugin mounts the generated `ctx.remote.teams` contribution from [`@deepseek-ai/dsh-team/remote`](../team/README.md); it does not extend the stable API Proxy or store authoritative Team state. +Private Web Agent Teams presentation. It contributes one conversation-header action containing the current roster and shared task board. The Client plugin mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/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. +Opening the panel calls `agentTeams/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 only after that reload succeeds; a reload failure remains visible instead. 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 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 `agentTeams/createTask` and `agentTeams/updateTask`. Every update sends the displayed revision. Create and update rejections remain explicit business results. Starting either operation invalidates older refreshes, and success reloads the complete Team view so derived fields on every task stay current. A `team-task-conflict` result displays a stale-state notice only after that reload succeeds; a reload failure remains visible instead. 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. +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-experimental-agent-team-web-profile`](../agent-team-web-profile/README.md) after the stable Web bundle and the Host-side Agent Teams profile. ## Model Experience diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 2d4ac6b09a..533350624c 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -1,14 +1,14 @@ -# @deepseek-ai/dsh-client-ui-agent-team +# @deepseek-ai/dsh-experimental-client-ui-agent-team [English](README.md) | 中文 -私有 Web Agent Teams 呈现包。它向会话页头提供一个包含当前 roster 与共享任务板的 action。Client plugin 挂载来自 [`@deepseek-ai/dsh-team/remote`](../team/README.md) 的生成式 `ctx.remote.teams` contribution;它不扩展稳定 API Proxy,也不存储权威 Team 状态。 +私有 Web Agent Teams 呈现包。它向会话页头提供一个包含当前 roster 与共享任务板的 action。Client plugin 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md) 的生成式 `ctx.remote.agentTeams` contribution;它不扩展稳定 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 专用字段。 +打开 panel 会调用 `agentTeams/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。 +任务板展示 task identity、owner、blocker、readiness、提示性 write scope 与重叠 warning。人类可以通过 `agentTeams/createTask` 与 `agentTeams/updateTask` 创建、编辑、分配或取消分配、完成、重开和删除任务。每次 update 都发送当前显示的 revision,create 与 update rejection 都保留为显式 business result。任一 operation 开始时都会让更早的 refresh 失效,成功后则重新读取完整 Team view,使所有 task 的派生字段保持最新。收到 `team-task-conflict` 结果后,UI 仅在重新读取成功后显示状态陈旧提示;如果重新读取失败,则保留该错误。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) 安装本包。 +Root export 在 Host 上不执行行为。Client export 负责 locale 与 slot 注册,Cordis 会随 plugin fiber dispose 两者。在稳定 Web bundle 与 Host 侧 Agent Teams profile 之后,通过 [`@deepseek-ai/dsh-experimental-agent-team-web-profile`](../agent-team-web-profile/README.md) 安装本包。 ## 模型体验 diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 7ed1a57cac..010ba2fae3 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -32,7 +32,7 @@ export interface TeamActionInjected { description: string blockedBy: TeamTaskId[] writeScopes: string[] - }) => Promise> + }) => Promise updateTask: (sessionId: SessionId, input: { taskId: TeamTaskId expectedRevision: number @@ -81,19 +81,6 @@ function statusKey(status: TeamTask['status']): TeamKey { } } -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, @@ -106,7 +93,7 @@ export function TeamAction({ const [createDraft, setCreateDraft] = useState(EMPTY_DRAFT) const [editing, setEditing] = useState(null) const [editDraft, setEditDraft] = useState(EMPTY_DRAFT) - const [pendingTask, setPendingTask] = useState(null) + const [pendingTasks, setPendingTasks] = useState>(() => new Set()) const sessionRef = useRef(sessionId) const refreshGeneration = useRef(0) sessionRef.current = sessionId @@ -121,7 +108,7 @@ export function TeamAction({ setCreateDraft(EMPTY_DRAFT) setEditing(null) setEditDraft(EMPTY_DRAFT) - setPendingTask(null) + setPendingTasks(new Set()) }, [sessionId]) const refresh = useCallback(async (): Promise => { @@ -148,37 +135,42 @@ export function TeamAction({ const settleTask = useCallback(async ( taskId: string, - operation: Promise, + operation: () => Promise, ): Promise => { const requestedSession = sessionId - setPendingTask(taskId) - const result = await operation - if (sessionRef.current !== requestedSession) return undefined - setPendingTask(null) - if (!result.ok) { - setError(failureText(result.error)) - return undefined - } - if (!result.value.ok) { - if (result.value.error.code === 'team-task-conflict') { - const reloaded = await refresh() - if (sessionRef.current !== requestedSession) return undefined - if (reloaded) setError(t('conflict')) - } else { - setError(failureText(result.value.error)) - } - return undefined - } - const task = result.value.value invalidateRefresh() - setError(null) - setView(current => loadedView(current, loaded => ({ - ...loaded, - tasks: task.status === 'deleted' - ? loaded.tasks.filter(candidate => candidate.id !== task.id) - : replaceTask(loaded.tasks, task), - }))) - return task + setPendingTasks(current => new Set(current).add(taskId)) + try { + const result = await operation() + if (sessionRef.current !== requestedSession) return undefined + if (!result.ok) { + setError(failureText(result.error)) + return undefined + } + if (!result.value.ok) { + if (result.value.error.code === 'team-task-conflict') { + const reloaded = await refresh() + if (sessionRef.current !== requestedSession) return undefined + if (reloaded) setError(t('conflict')) + } else { + setError(failureText(result.value.error)) + } + return undefined + } + const task = result.value.value + setError(null) + await refresh() + if (sessionRef.current !== requestedSession) return undefined + return task + } finally { + if (sessionRef.current === requestedSession) { + setPendingTasks((current) => { + const next = new Set(current) + next.delete(taskId) + return next + }) + } + } }, [invalidateRefresh, refresh, sessionId, t]) const submitCreate = async (): Promise => { @@ -186,28 +178,15 @@ export function TeamAction({ 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, { + const created = await settleTask('create', () => createTask(sessionId, { subject, description, blockedBy: taskIds(createDraft.blockers), writeScopes: items(createDraft.scopes), - }) - if (sessionRef.current !== requestedSession) return - setPendingTask(null) - if (!result.ok) { - setError(failureText(result.error)) - return - } - invalidateRefresh() - setView(current => loadedView(current, loaded => ({ - ...loaded, - tasks: [...loaded.tasks, result.value], - }))) + })) + if (created === undefined) return setCreateDraft(EMPTY_DRAFT) setCreating(false) - setError(null) } const startEdit = (task: TeamTask): void => { @@ -222,7 +201,7 @@ export function TeamAction({ const submitEdit = async (task: TeamTask): Promise => { const requestedSession = sessionId - const edited = await settleTask(task.id, updateTask(requestedSession, { + const edited = await settleTask(task.id, () => updateTask(requestedSession, { taskId: task.id, expectedRevision: task.revision, action: 'edit', @@ -237,35 +216,13 @@ export function TeamAction({ setEditing(null) return } - setPendingTask(task.id) - const dependencyResult = await updateTask(requestedSession, { + const dependencyTask = await settleTask(task.id, () => updateTask(requestedSession, { taskId: task.id, expectedRevision: edited.revision, action: 'set_dependencies', blockedBy, - }) - if (sessionRef.current !== requestedSession) return - setPendingTask(null) - if (!dependencyResult.ok) { - setError(failureText(dependencyResult.error)) - return - } - if (!dependencyResult.value.ok) { - if (dependencyResult.value.error.code === 'team-task-conflict') { - const reloaded = await refresh() - if (sessionRef.current !== requestedSession) return - if (reloaded) setError(t('conflict')) - } else { - setError(failureText(dependencyResult.value.error)) - } - return - } - const dependencyTask = dependencyResult.value.value - invalidateRefresh() - setView(current => loadedView(current, loaded => ({ - ...loaded, - tasks: replaceTask(loaded.tasks, dependencyTask), - }))) + })) + if (dependencyTask === undefined) return setEditing(null) } @@ -339,7 +296,7 @@ export function TeamAction({ { void submitCreate() }} onCancel={() => { setCreating(false) }} t={t} @@ -353,7 +310,7 @@ export function TeamAction({ key={task.id} draft={editDraft} setDraft={setEditDraft} - pending={pendingTask === task.id} + pending={pendingTasks.has(task.id)} onSave={() => { void submitEdit(task) }} onCancel={() => { setEditing(null) }} t={t} @@ -378,10 +335,10 @@ export function TeamAction({ {t('owner')} - {task.status === 'in_progress' && ( - )} {task.status === 'completed' && ( - )} - diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index 4dae63a1cb..0d2b1bac17 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -2,7 +2,6 @@ import type { TeamMemberView as TeamRosterMember, - TeamTaskView as TeamTask, TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' @@ -11,7 +10,9 @@ import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/c import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' -import { TeamAction, type TeamActionInjected, type TeamActionResult } from './TeamAction.tsx' +import { + TeamAction, type TeamActionInjected, type TeamActionResult, type TeamTaskActionResult, +} from './TeamAction.tsx' import { en, zh, type TeamKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -25,7 +26,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export const inject = ['sessions', 'remote', 'slots', 'locale'] function registerUi(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register('team', { zh, en }), 'ui-team: dictionaries') + ctx.effect(() => ctx.locale.register('team', { zh, en }), 'client-ui-agent-team: dictionaries') const sessions = ctx.sessions const leadSessionId = (sessionId: SessionId): SessionId => { const address = sessions.binding(sessionId)?.session.getSnapshot().subagent?.address @@ -36,7 +37,7 @@ function registerUi(ctx: ClientContext): void { async load(sessionId): Promise> { return await ctx.remote.agentTeams.view(leadSessionId(sessionId)) }, - async createTask(sessionId, input): Promise> { + async createTask(sessionId, input): Promise { return await ctx.remote.agentTeams.createTask(leadSessionId(sessionId), input) }, async updateTask(sessionId, input) { diff --git a/packages/experimental/client-ui-agent-team/src/invariant.ts b/packages/experimental/client-ui-agent-team/src/invariant.ts index d1d44ecfe8..954600b3ed 100644 --- a/packages/experimental/client-ui-agent-team/src/invariant.ts +++ b/packages/experimental/client-ui-agent-team/src/invariant.ts @@ -6,13 +6,17 @@ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-team' /** Cordis companion plugin name. */ -export const name = 'client-ui-team-invariant' +export const name = 'client-ui-agent-team-invariant' /** Invariant registry dependency. */ export const inject = ['invariants'] /** No runtime invariant: RPC is authoritative and the package owns only one disposable slot registration. */ const install: InvariantInstaller = () => {} -/** Register this package's invariant ownership. */ +/** + * Register this package's invariant ownership. + * @param ctx - Cordis Context carrying the invariant registry. + * @returns disposer for the package registration. + */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 8351578df4..c07338fa26 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -77,7 +77,7 @@ function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): Tea function actions(overrides: Partial = {}): TeamActionInjected { return { load: () => Promise.resolve({ ok: true, value: view }), - createTask: () => Promise.resolve({ ok: true, value: { ...task, id: TASK_2, subject: 'New task' } }), + createTask: () => Promise.resolve(taskSuccess({ ...task, id: TASK_2, subject: 'New task' })), updateTask: () => Promise.resolve({ ok: true, value: { ok: true, value: { ...task, revision: 2 } }, @@ -155,9 +155,11 @@ describe('TeamAction', () => { it('keeps a successful task mutation newer than an in-flight refresh', async () => { const stale = Promise.withResolvers>() + const completedView = { ...view, tasks: [{ ...task, revision: 2, status: 'completed' as const }] } const load = vi.fn() .mockResolvedValueOnce({ ok: true, value: view }) .mockImplementationOnce(() => stale.promise) + .mockResolvedValueOnce({ ok: true, value: completedView }) const updateTask = vi.fn(() => Promise.resolve( taskSuccess({ ...task, revision: 2, status: 'completed' }), )) @@ -177,10 +179,15 @@ describe('TeamAction', () => { it('keeps a created task newer than an in-flight refresh', async () => { const stale = Promise.withResolvers>() + const createdTask = { ...task, id: TASK_2, subject: 'New task' } const load = vi.fn() .mockResolvedValueOnce({ ok: true, value: view }) .mockImplementationOnce(() => stale.promise) - render() + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [...view.tasks, createdTask] } }) + render( Promise.resolve(taskSuccess(createdTask)), + }))} />) fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) await screen.findByText('Implement runtime') @@ -196,6 +203,106 @@ describe('TeamAction', () => { expect(screen.getByText('New task')).toBeTruthy() }) + it('keeps task and create failures newer than an in-flight refresh', async () => { + const staleTask = Promise.withResolvers>() + const taskLoad = vi.fn() + .mockResolvedValueOnce({ ok: true, value: view }) + .mockImplementationOnce(() => staleTask.promise) + const first = render( Promise.resolve(taskRejected('task rejected')), + }))} />) + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + await screen.findByText('Implement runtime') + fireEvent.click(screen.getByRole('button', { name: zh.refresh })) + fireEvent.click(screen.getByRole('button', { name: /完成/u })) + expect(await screen.findByText('task rejected (team-rejected)')).toBeTruthy() + staleTask.resolve({ ok: true, value: view }) + await Promise.resolve() + expect(screen.getByText('task rejected (team-rejected)')).toBeTruthy() + first.unmount() + + const staleCreate = Promise.withResolvers>() + const createLoad = vi.fn() + .mockResolvedValueOnce({ ok: true, value: view }) + .mockImplementationOnce(() => staleCreate.promise) + render( Promise.resolve(taskRejected('create rejected')), + }))} />) + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + await screen.findByText('Implement runtime') + fireEvent.click(screen.getByRole('button', { name: zh.refresh })) + fireEvent.click(screen.getByRole('button', { name: /新建任务/u })) + fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Rejected task' } }) + fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Rejected details' } }) + fireEvent.click(screen.getByRole('button', { name: '保存' })) + expect(await screen.findByText('create rejected (team-rejected)')).toBeTruthy() + staleCreate.resolve({ ok: true, value: view }) + await Promise.resolve() + expect(screen.getByText('create rejected (team-rejected)')).toBeTruthy() + }) + + it('tracks simultaneous create and task mutations independently', async () => { + const create = Promise.withResolvers() + const createdTask = { ...task, id: TASK_2, subject: 'Concurrent task' } + const completedTask = { ...task, revision: 2, status: 'completed' as const } + const load = vi.fn() + .mockResolvedValueOnce({ ok: true, value: view }) + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [completedTask] } }) + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [completedTask, createdTask] } }) + const createTask = vi.fn(() => create.promise) + render() + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + await screen.findByText('Implement runtime') + fireEvent.click(screen.getByRole('button', { name: /新建任务/u })) + fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Concurrent task' } }) + fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Concurrent details' } }) + const save = screen.getByRole('button', { name: '保存' }) + fireEvent.click(save) + await waitFor(() => { expect(save.disabled).toBe(true) }) + + const complete = screen.getByRole('button', { name: /完成/u }) + expect(complete.disabled).toBe(false) + fireEvent.click(complete) + expect(await screen.findByRole('button', { name: /重开/u })).toBeTruthy() + expect(save.disabled).toBe(true) + fireEvent.click(save) + expect(createTask).toHaveBeenCalledTimes(1) + + create.resolve(taskSuccess(createdTask)) + expect(await screen.findByText('Concurrent task')).toBeTruthy() + expect(screen.queryByRole('button', { name: '保存' })).toBeNull() + }) + + it('reloads derived fields for every task after a mutation', async () => { + const related = { + ...task, + id: TASK_2, + subject: 'Related task', + writeScopeWarnings: ['old warning'], + } + const completed = { ...task, revision: 2, status: 'completed' as const } + const refreshed = { + ...view, + tasks: [completed, { ...related, writeScopeWarnings: ['derived warning refreshed'] }], + } + const load = vi.fn() + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [task, related] } }) + .mockResolvedValueOnce({ ok: true, value: refreshed }) + render( Promise.resolve(taskSuccess(completed)), + }))} />) + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + await screen.findByText('old warning') + fireEvent.click(screen.getAllByRole('button', { name: /完成/u })[0]!) + + expect(await screen.findByText('derived warning refreshed')).toBeTruthy() + expect(screen.queryByText('old warning')).toBeNull() + expect(load).toHaveBeenCalledTimes(2) + }) + it('creates a task from normalized blocker and write-scope lists', async () => { const createTask = vi.fn(actions().createTask) render() @@ -261,7 +368,11 @@ describe('TeamAction', () => { } return Promise.resolve(taskSuccess(current)) }) - render() + const load = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { ...view, tasks: current.status === 'deleted' ? [] : [current] }, + })) + render() fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) await screen.findByText('Implement runtime') @@ -336,6 +447,7 @@ describe('TeamAction', () => { const dependencyLoad = vi.fn() .mockResolvedValueOnce({ ok: true, value: view }) + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [{ ...task, revision: 2, subject: 'Edited' }] } }) .mockResolvedValueOnce(remoteFailure('dependency reload failed')) const dependencyUpdate = vi.fn() .mockResolvedValueOnce(taskSuccess({ ...task, revision: 2, subject: 'Edited' })) @@ -421,7 +533,7 @@ describe('TeamAction', () => { expect(await screen.findByText('create failed (internal)')).toBeTruthy() second.unmount() - const pending = Promise.withResolvers>() + const pending = Promise.withResolvers() const third = render( pending.promise }))} />) fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) await screen.findByText('Implement runtime') @@ -430,7 +542,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Late description' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) third.rerender() - pending.resolve({ ok: true, value: { ...task, id: 'late-task' as TeamTaskId } }) + pending.resolve(taskSuccess({ ...task, id: 'late-task' as TeamTaskId })) await Promise.resolve() expect(screen.queryByText('Late task')).toBeNull() }) @@ -477,6 +589,27 @@ describe('TeamAction', () => { expect(screen.queryByText(zh.conflict)).toBeNull() }) + it('does not settle a successful task after its reload switches sessions', async () => { + const reload = Promise.withResolvers>() + const load = vi.fn() + .mockResolvedValueOnce({ ok: true, value: view }) + .mockImplementationOnce(() => reload.promise) + const rendered = render( Promise.resolve(taskSuccess({ ...task, revision: 2, status: 'completed' })), + }))} />) + fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + await screen.findByText('Implement runtime') + fireEvent.click(screen.getByRole('button', { name: /完成/u })) + await waitFor(() => { expect(load).toHaveBeenCalledTimes(2) }) + + rendered.rerender() + reload.resolve({ ok: true, value: { ...view, tasks: [{ ...task, revision: 2, status: 'completed' }] } }) + await Promise.resolve() + await Promise.resolve() + expect(screen.queryByText('Implement runtime')).toBeNull() + }) + it('contains edit and dependency failures and supports form cancellation and unassignment', async () => { const { ownerName: _ownerName, ...unownedTask } = task const updateTask = vi.fn() @@ -557,6 +690,7 @@ describe('TeamAction', () => { it('reloads a dependency conflict and ignores dependency settlement after a session switch', async () => { const load = vi.fn() .mockResolvedValueOnce({ ok: true, value: view }) + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [{ ...task, revision: 2, subject: 'Conflict edit' }] } }) .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [{ ...task, revision: 3 }] } }) const conflictUpdate = vi.fn() .mockResolvedValueOnce(taskSuccess({ ...task, revision: 2, subject: 'Conflict edit' })) @@ -569,12 +703,13 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) expect(await screen.findByText(zh.conflict)).toBeTruthy() - expect(load).toHaveBeenCalledTimes(2) + expect(load).toHaveBeenCalledTimes(3) first.unmount() const dependencyReload = Promise.withResolvers>() const dependencyLoad = vi.fn() .mockResolvedValueOnce({ ok: true, value: view }) + .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [{ ...task, revision: 2, subject: 'Late edit' }] } }) .mockImplementationOnce(() => dependencyReload.promise) const staleUpdate = vi.fn() .mockResolvedValueOnce(taskSuccess({ ...task, revision: 2, subject: 'Late edit' })) @@ -586,7 +721,7 @@ describe('TeamAction', () => { fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Late edit' } }) fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } }) fireEvent.click(screen.getByRole('button', { name: '保存' })) - await waitFor(() => { expect(dependencyLoad).toHaveBeenCalledTimes(2) }) + await waitFor(() => { expect(dependencyLoad).toHaveBeenCalledTimes(3) }) second.rerender() dependencyReload.resolve({ ok: true, value: { ...view, tasks: [{ ...task, revision: 3 }] } }) await Promise.resolve()