diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 2dbf920a69..e42398340b 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -2662,6 +2662,9 @@ "simplification/2026-09-19-deepseek-messages-only.i18n.yaml": "sha256:7ffd708c5823c39cc9536178292e7e7fa57db5267bbc9acdda0a87e4953d5ba8", "simplification/2026-09-19-deepseek-messages-only.md": "sha256:241861200ce73e02f77d182633d6ea10b1f29c696c81bff002680762db01d9b5", "simplification/2026-09-19-deepseek-messages-only.zh.md": "sha256:311ef6f8f4cf60dae2a43ca94d772028b645631501fb3648aaeb406ba174991e", + "simplification/2026-09-26-team-direct-inbox.i18n.yaml": "sha256:9a6f1137f0c68d976fda4f44c5c301b851a8ee8e46965f3ea808ec5551045fb0", + "simplification/2026-09-26-team-direct-inbox.md": "sha256:88f7d9be3ae0b740ddb5a61a16e3c64044a207584b973acab8c3a40c36a7ff30", + "simplification/2026-09-26-team-direct-inbox.zh.md": "sha256:d6d1fafa4aa7035a11930cca3bf998d676a62b5f9b75176dff69c3c5ff6b92f0", "testing/2026-06-11-property-based-testing.i18n.yaml": "sha256:9d378912101ad30203f1fcbcad23e0f5d6ec6f39e04a83f17f90887b5c5d761e", "testing/2026-06-11-property-based-testing.md": "sha256:cca3b6299784cff94965f21b6d59966109a0bb41fdc2f8cf56653fa6c417a20a", "testing/2026-06-11-property-based-testing.zh.md": "sha256:09f3d1e2defd2792f87fb0e9892fb7e1db006f3c75ea4581f97e9cde4ea98175", diff --git a/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.i18n.yaml new file mode 100644 index 0000000000..50788468df --- /dev/null +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.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/archived/simplification/2026-09-26-team-direct-inbox.md +2026-09-26-team-direct-inbox.md: 9b90d7ae30e0b711c602de9280cc11845d0525f0 +2026-09-26-team-direct-inbox.zh.md: 8838fb4a2824838b76142f30a6ff4d0aedf0276b diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md rename to .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md index 58d18d0ecf..9b90d7ae30 100644 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.md @@ -1,6 +1,7 @@ # Agent Note: Team messages use the target inbox Status: implemented +Archived: 2026-10-15 English | [中文](2026-09-26-team-direct-inbox.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md rename to .agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md index 6928631c8f..8838fb4a28 100644 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.zh.md +++ b/.agents/notes/archived/simplification/2026-09-26-team-direct-inbox.zh.md @@ -1,6 +1,7 @@ # Agent Note: Team 消息使用目标 inbox Status: implemented +Archived: 2026-10-15 [English](2026-09-26-team-direct-inbox.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index ceccd5ead5..a41dd3bd98 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -21,8 +21,8 @@ en: 0a4240d88cca240a zh: 8f5f1527caf571cd /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end: - en: 15fb56befd328339 - zh: 0ffb10ada7e52805 + en: dd05aa1ee608c414 + zh: 2863ced0086e3122 /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation: en: 37e2fa1fe5cb2b95 zh: 80e513db87709cb4 @@ -33,8 +33,8 @@ en: 94ca0d09e6fd1e3a zh: d5949a2bdec795ba /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences: - en: 16e1f2467c559315 - zh: a714ec6ff2722cf1 + en: 624efdd136029771 + zh: 7d60207147e631fe /agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered: en: b8f036f50cff7cd8 zh: 6d623c4a1f4314b1 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 28d718586b..15c0a39295 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -67,8 +67,8 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a 1. The kernel mounts the vendored Loader and injects the module system as `internal` before any entry exists. Ordering matters: `tree.import`'s bare-import fallback must never run in a browser. 2. It creates every graph row uniformly. Importing the modules row returns the memoized bootstrap exports, whose `apply()` reads that tree's `Loader.internal` and provides the same instance as `ctx.modules`; rows that require that service remain PENDING until then, so the modules row needs no special creation position. Render assembly is an ordinary host-graph row provided by `dsh-client-ui-renderer`; the kernel appends no assembly pseudo-entry. 3. Graph order governs synchronous factory availability; Cordis activation remains independent and proceeds through service waiting. -4. `settled` = every entry created + `loader.await()` quiescent + an all-ACTIVE sweep. The sweep lists each import-failed, FAILED, or PENDING fiber with its missing services. It exists because cordis inject waits have no timeout — the sweep is the fail-loud floor. -5. The framework-free loading page projects real fiber states via `internal/status`. After the sweep, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. +4. `settled` = every entry created + `loader.await()` quiescent + the boot audit. The audit lists each import-failed, FAILED, or PENDING fiber with its activation error or missing services. It exists because cordis inject waits have no timeout. An inactive entry on the browser required list rejects startup; other inactive entries produce one console warning ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). +5. The framework-free loading page projects real fiber states via `internal/status`. After the audit accepts the tree, the kernel calls `ctx.uiRenderer.mount(container)` and replaces the page with the real UI in one pass. ### Live graph reconciliation @@ -104,7 +104,7 @@ The current package inventory and build forms live in the [client shell layering One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references. -Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. +Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the boot audit; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file. Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 1bf50a15ba..95333823eb 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -67,8 +67,8 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的 1. 内核挂载 vendored Loader,在任何 entry 存在之前就把模块系统注入为 `internal`。顺序有讲究:`tree.import` 的裸 import 兜底分支在浏览器里绝不能跑到。 2. 它统一创建每个 graph row。Import modules row 会返回记忆化的 bootstrap exports,其 `apply()` 读取该树的 `Loader.internal`,把同一个实例提供为 `ctx.modules`;需要该 service 的 row 会保持 PENDING 直至此时,因此 modules row 无需特殊创建位置。渲染组装是由 `dsh-client-ui-renderer` 提供的普通 host graph row;内核不追加组装伪 entry。 3. Graph 顺序治理同步 factory 可用性;Cordis 激活与之独立,仍经服务等待推进。 -4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。 -5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 +4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 启动审计。审计列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其激活错误或缺失的服务。它存在的理由:cordis 的 inject 等待没有超时。浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。 +5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。审计接受插件树后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。 ### 动态图对账 @@ -104,7 +104,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。 -接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 +接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在启动审计时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml index 260adf6610..97e7927b58 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.i18n.yaml @@ -3,23 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md /agent-note-adjacent-agents-share-one-steer-send-message-operation: - en: 758d31ae04ce0d4f - zh: 112475efbd52b9f6 + en: 9d0af2bae38b1eec + zh: 8a6e8502a5195548 /agent-note-adjacent-agents-share-one-steer-send-message-operation/problem: - en: c988b8a3be4e6f19 - zh: b58011fd8d2e878e + en: 5dc4c218f648c27e + zh: b48531fac94b25db /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision: - en: 1188c699ce6e6ca2 - zh: 4d5b974cd1216f06 + en: f4789878ac0e79b2 + zh: 68eb68d34cba810d /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction: - en: 4b0a25389324fa91 - zh: c2cdd9f7e40ef43b + en: 62284bd7dd804188 + zh: b3a8dfebffc01bdb /agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition: - en: 5a9ec6478cf7430c - zh: f4c2f067cc72040e + en: 8c6d355d91ec83d2 + zh: fcc74d1e6ef477bf /agent-note-adjacent-agents-share-one-steer-send-message-operation/alternatives-considered: - en: 1f62d5d938144666 - zh: dec0f30228c5b7a7 + en: 7386e88e7051dce7 + zh: dcea5b9e30374be3 /agent-note-adjacent-agents-share-one-steer-send-message-operation/consequences: - en: d55de74742c3af4b - zh: d894f9a33c8e4691 + en: 9c23dd6ace029ec3 + zh: f08ef3f142534843 diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md index 4efb9d334e..a4cfbc91cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md @@ -4,26 +4,25 @@ Status: implemented English | [中文](2026-08-27-adjacent-agent-steer-messaging.zh.md) +The adjacency, discovery, and parent-lifetime restrictions below are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md); the remaining decisions stay active. The tool identity marker and initial-task communication guidance below are superseded by [tool-owned communication](2026-10-22-subagent-tool-owned-communication.md). + ## Problem Continuable Agents originally used direction-specific model controls. A parent called `send_message({ subagent_id, message })`, which delegated to a FIFO `followup` service operation. A child instead received a child-scoped `report({ output })` tool, a `tool:report` system-prompt section, and deployment-selected quiet or waking delivery. The tools described one adjacent-Agent operation through different schemas, service paths, source attribution, and scheduling. -A continuable child owns its own Session, so its parent does not automatically receive the child's transcript, tool output, or reasoning. The return path must therefore remain explicit and repeatable: a child may send progress before it finishes, remain available after sending, or fail before it can cooperate. Turning every final assistant message into an implicit result would conflate turn completion with model-selected communication and would not cover abnormal endings. +A continuable child owns its own Session, so its parent does not automatically receive the child's transcript, tool output, or reasoning. Explicit messages let a child send actionable findings before finishing. The runtime separately delivers terminal output in a settlement notice when the direct creator is still online. The child-only tool and system-prompt section also preceded every inherited fork turn. They made a continuable fork child's request head differ from its parent's before the history that fork exists to reuse, forcing the provider to prefill the entire copied transcript again. ## Decision -`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target on one adjacent edge: +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` is the only public model-authored message operation. The continuation manager accepts only the exact live sender and a target in the same root tree; [tree-wide communication](2026-10-16-tree-wide-agent-communication.md) owns authorization and discovery. -- parent to direct continuable child, authorized by the child's durable `SessionHeader.parentSession`; -- resident continuable child to its exact live direct parent, authorized by the child's Activation. +Self-targets, stale Agent objects, unknown targets, and external executions are not alternate routes. The operation has no caller-supplied source, delivery mode, offline root mailbox, or provider dispatch. -Siblings, self-targets, ancestors beyond one edge, stale Agent objects, unknown targets, and one-shot children are not alternate routes. The operation has no caller-supplied source, delivery mode, offline parent mailbox, or provider dispatch. +Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent target is cold-resumed under the root before the same Steer delivery; its creation ancestors can remain offline. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. -Every accepted message uses `Agent.steer()`. A running target receives it at the nearest step boundary; an idle target starts a turn. An absent direct child is cold-resumed through the existing continuation lifecycle before the same Steer delivery. The manager retains waking-send accounting so a continuation-managed target cannot settle between synchronous inbox insertion and driver admission. - -Every direction uses one durable source. The service derives `senderSessionId` from the authorized Agent and frames the model-visible content as `Agent sent a message:`, so attribution cannot diverge from authority. +Every direction uses one durable source. The service derives `senderSessionId` from the exact live sender and frames content as `Agent sent a message:`. ```ts import type { SessionId } from '@deepseek-ai/dsh-session' @@ -46,15 +45,15 @@ interface SendMessageInput { } ``` -Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. +Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; none receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id, automatic final-answer delivery conditions, and guidance to send earlier actionable findings to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix. -The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate. +The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The separately attributed `subagent-settled` notice records the Activation's outcome and terminal output only when its direct creator is still online and delivery is `parent`. Human browser prompts are not model-authored Agent messages. The remote prompt path keeps a private Queue delivery so each human prompt remains a distinct turn. Interrupt behavior and settlement delivery remain independent. ### Complete removal and reintroduction condition -The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that an adjacent `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. +The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, `tool:report` prompt section, `reportDelivery` configuration, report-specific message source, catalog entries, composition rows, and supported-behavior snapshots are absent. The unified tool gives up the recipient-free child shortcut and the old ability for a structural return tool to survive an explicit child allow-list. Those capabilities return only if a concrete use case requires semantics that a tree member’s `agent_id` and fixed Steer cannot express; reintroducing them requires a distinct model operation and prefix-cost evidence, not an alias over `sendMessage()`. ## Alternatives considered @@ -64,16 +63,16 @@ The standalone `@deepseek-ai/dsh-tool-subagent-report` package, `report` schema, **Make `report` global.** Roots, one-shot children, remote children, and agentless callers cannot derive a report recipient. Advertising it globally would make schema visibility disagree with authority, while `send_message` already makes the recipient explicit. -**Turn every child final message into an implicit send.** A long-lived child may have nothing useful to send in one turn and several findings in another. Automatic delivery would merge model-authored communication with the runtime's settlement account and could not replace the unconditional notice on errors, cancellation, or token exhaustion. +**Treat the final answer as a model-authored send.** A settlement notice must preserve terminal output and abnormal endings even when the child cannot cooperate. It keeps runtime attribution distinct from findings the child explicitly chose to send. -**Rely only on the tool description.** A tool description helps after the model considers that tool; the failure mode is a child that believes it is finished without considering any return call. Initial-task guidance reaches that decision without changing the inherited system or tool prefix. +**Rely only on the tool description.** A tool description helps after the model considers that tool; initial-task guidance also explains which earlier findings should reach the parent and what the final answer must contain, without changing the inherited system or tool prefix. **Keep quiet delivery as deployment policy.** A quiet model-authored message can be accepted while an idle target never reads it. Fixed Steer gives both directions one delivery meaning and preserves accepted order with later settlement notices. ## Consequences - Model consumers expose one `send_message({ agent_id, message })` definition to parents and children, with no model-selected Queue versus Steer parameter. -- The continuation manager remains the sole owner of adjacency authorization, residency, cold resume, waking admission, and teardown races. +- The continuation manager remains the sole owner of tree authorization, residency, cold resume, waking admission, and teardown races. - Accepted messages may extend a running target's current turn; messages waiting together share next-step FIFO ordering. - Caller cancellation owns work only until inbox acceptance and does not retract an accepted message or dispose the target. - The initial task carries JSON-encoded dynamic parent addressing after a fork prefix, while the request-head system prompt and tool ordering remain reusable. diff --git a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md index b7ed157f54..d907eaeac0 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md @@ -4,26 +4,25 @@ Status: implemented [English](2026-08-27-adjacent-agent-steer-messaging.md) | 中文 +邻接消息、发现范围和父子生命周期限制已由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)替代;其余决策仍然有效。 下文的工具身份标记和初始任务通信指导由[工具拥有通信指导](2026-10-22-subagent-tool-owned-communication.zh.md)取代。 + ## 问题 可继续 Agent 最初使用方向专属的模型控制。parent 调用 `send_message({ subagent_id, message })`,委托给 FIFO `followup` 服务操作。child 则获得 child 作用域的 `report({ output })` 工具、`tool:report` 系统提示词 section,以及由部署选择的静默或唤醒投递。两个工具用不同 schema、服务路径、来源与调度描述同一个相邻 Agent 操作。 -可继续 child 拥有自己的 Session,因此 parent 不会自动收到 child 的 transcript(文本记录)、工具输出或推理。返回路径必须保持显式且可重复:child 可以在结束前发送进度、发送后仍保持可用,也可能在来得及配合前失败。把每条最终 assistant 消息变成隐式结果会混淆轮次完成与模型选择的通信,而且无法覆盖异常结束。 +可继续 child 拥有自己的 Session,因此 parent 不会自动收到 child 的 transcript(文本记录)、工具输出或推理。显式消息让 child 在结束前发送可操作的发现。直接创建者仍在线时,runtime 另行通过结算通知投递终态输出。 child 专属工具与系统提示词 section 还位于每个继承 fork 轮次之前。它们让可继续 fork child 的请求头在 fork 旨在复用的历史之前就与 parent 不同,迫使提供方重新预填充整份复制 transcript。 ## 决策 -`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender 与一条相邻边上的目标: +`SubagentRuntime.sendMessage(sender, targetId, content, { signal })` 是唯一公开的模型编写消息操作。继续执行管理器只接受确切在线 sender 与同一根树中的目标;[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)负责授权与发现规则。 -- parent 到直接可继续 child,由 child 的持久化 `SessionHeader.parentSession` 授权; -- 驻留的可继续 child 到其确切在线直接 parent,由 child 的 Activation 授权。 +自身目标、过期 Agent 对象、未知目标和外部执行不是替代路由。操作不接受调用方提供的 source、投递模式、离线根邮箱或提供方分发。 -sibling、自身目标、超过一条边的 ancestor、陈旧 Agent 对象、未知目标与一次性 child 都不是替代路由。该操作没有调用方提供的 source、投递模式、离线 parent mailbox 或提供方分发。 +每条被接受的消息都使用 `Agent.steer()`。运行中目标在最近 step 边界接收消息;空闲目标启动轮次。不在线目标先在根下冷恢复,再接受同一 Steer 投递;其创建祖先可以保持离线。管理器保留唤醒发送记账,因此受继续执行管理的目标不会在同步 inbox 插入与 driver 准入之间结算。 -每条被接受的消息都使用 `Agent.steer()`。运行中目标在最近 step 边界接收消息;空闲目标启动轮次。缺失的直接 child 会先通过现有继续执行生命周期冷恢复,再接受同一 Steer 投递。管理器保留唤醒发送记账,因此受继续执行管理的目标不会在同步 inbox 插入与 driver 准入之间结算。 - -两个方向使用同一种持久来源。服务从已授权 Agent 推导 `senderSessionId`,并把模型可见内容组装为 `Agent sent a message:`,因此来源信息不会偏离权限。 +所有方向使用同一种持久 source。服务从确切在线发送方推导 `senderSessionId`,并以 `Agent sent a message:` 包装内容。 ```ts import type { SessionId } from '@deepseek-ai/dsh-session' @@ -46,15 +45,15 @@ interface SendMessageInput { } ``` -parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 +parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;这些情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、最终回答的自动投递条件,以及提前发送可操作发现的指导追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。 -该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。 +该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。采用独立来源的 `subagent-settled` 通知仅在直接创建者仍在线且投递方式为 `parent` 时,记录 Activation 的结果与终态输出。 浏览器中的人类提示不是模型编写的 Agent 消息。远程提示路径保留私有 Queue 投递,使每条人类提示保持为独立轮次。中断行为与结算投递保持独立。 ### 完整移除与重新引入条件 -独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要相邻 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 +独立的 `@deepseek-ai/dsh-tool-subagent-report` 包、`report` schema、`tool:report` 提示词 section、`reportDelivery` 配置、report 专属消息来源、目录项、组合行和受支持行为快照均已不存在。统一工具放弃了无需接收方的 child 快捷方式,也放弃了让结构性返回工具绕过显式 child allow-list 的旧能力。只有具体用例需要树成员的 `agent_id` 与固定 Steer 无法表达的语义时,这些能力才会重新出现;重新引入需要独立的模型操作与前缀成本证据,而非 `sendMessage()` 之上的别名。 ## 考虑过的替代方案 @@ -64,16 +63,16 @@ parent 与 child 以相同注册表顺序继承相同定义。标准定义携带 **让 `report` 全局可见。** 根 Agent、一次性 child、远程 child 与无 Agent 调用方无法推导 report 接收方。全局宣传它会让 schema 可见性与权限不一致,而 `send_message` 已显式给出接收方。 -**把每条 child 最终消息变成隐式发送。** 长期运行的 child 可能在某个轮次没有值得发送的内容,在另一个轮次却有多条发现。自动投递会混合模型编写通信与 runtime 结算说明,而且无法替代错误、取消或 token 耗尽时的无条件通知。 +**将最终回答当作模型编写的发送。** 即使 child 无法配合,结算通知仍须保留终态输出与异常结束信息。它将 runtime 来源与 child 显式选择发送的发现区分开。 -**只依赖工具描述。** 工具描述会在模型考虑该工具后提供帮助;失败模式是 child 认为自己已经完成而根本没有考虑返回调用。初始任务指导能触及该决策,又不会改变继承的系统或工具前缀。 +**只依赖工具描述。** 工具描述会在模型考虑该工具后提供帮助;初始任务指导还说明哪些早期发现应发送给 parent,以及最终回答须包含什么,又不会改变继承的系统或工具前缀。 **保留静默投递作为部署策略。** 静默的模型编写消息可能被接受,但空闲目标永远不会读取它。固定 Steer 为两个方向提供一种投递含义,并保持与后续结算通知的接受顺序。 ## 后果 - 模型 Consumer 向 parent 与 child 公开一个 `send_message({ agent_id, message })` 定义,不提供模型选择的 Queue 与 Steer 参数。 -- 继续执行管理器仍是相邻关系授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 +- 继续执行管理器仍是整树授权、驻留、冷恢复、唤醒准入与拆卸竞态的唯一所有者。 - 被接受的消息可以延长运行中目标的当前轮次;一起等待的消息共享 next-step FIFO 顺序。 - 调用方取消只在 inbox 接受前掌管工作,不会撤回已接受消息或 dispose(资源释放)目标。 - 初始任务在 fork 前缀之后携带经过 JSON 编码的动态 parent 地址,而请求头系统提示词与工具顺序保持可复用。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index ddf55f9063..f8de1ed3b1 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml @@ -9,14 +9,14 @@ en: 98b49b900b8d2f84 zh: 557faac2976592be /agent-note-explicit-agent-identity-at-runtime-boundaries/decision: - en: 1c628cf9c743c639 - zh: d969b2475fe4a155 + en: 4a3ab5108f37520f + zh: d13fcd448101a95d /agent-note-explicit-agent-identity-at-runtime-boundaries/verification: - en: 85130faaa171036b - zh: 06e5e9fab32dbfb4 + en: ce1733edf03f0387 + zh: 8e1ca2b69e81968e /agent-note-explicit-agent-identity-at-runtime-boundaries/alternatives-considered: - en: 2c41456509d54d21 - zh: 5704ee9a2274e300 + en: 34f5f8571b21897c + zh: cf16094bc0027ca1 /agent-note-explicit-agent-identity-at-runtime-boundaries/consequences: - en: 08ab86b89fc3ec6f - zh: 3866e6557858543a + en: 0b5914067f14282e + zh: c41d21aeb28e357f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 16cd668f65..336151fa83 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -14,34 +14,34 @@ Without an explicit owner, `SubagentManager` creates and resumes children throug ## Decision -Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; each subagent Activation retains its exact runtime root; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. -Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` receives the Agent explicitly and installs through the supplied Context before Agent publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. -`SubagentManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. +`SubagentManager.rootOf(agent)` reads runtime membership from existing Activations; both the child and root must match their live registry objects. Ordinary Sessions resolve to themselves, and unmanaged subagent headers grant no membership. Durable `parentSession` describes creation edges; an Activation retains the exact root while intermediate creators are offline, and same-id replacements cannot inherit it. Root-only consumers classify the explicit subagent origin. The [scope reference](../../../../packages/core/scope/README.md) and [Agent loop reference](../../../../packages/core/agent-loop/README.md) document registration and lifecycle behavior. The [initiator-scope decision](2026-07-15-agent-initiator-scope.md) owns private-chain rationale; explicit runtime ownership does not change causal initiation or registration lifetime. ## Verification -Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. +Managed-child tests verify intermediate-creator disposal and exact-instance checks after child or root replacement. SDK approval tests verify routing during a turn after the direct creator exits. Schedule and user-question tests reject delegated Sessions while ordinary historical forks remain independent. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Agent publication; standing-preset tests verify per-Session policy sampling and inheritance. ## Alternatives considered **Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. -**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the subagent Activation retains the exact root supplied at admission. **Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. **Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. -**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. +**Use durable Session lineage.** `parentSession` records creation ancestry across process lifetimes. An intermediate creator can be offline while its descendant remains managed by a live root; durable ids also cannot distinguish replaced Agent instances. Historical ordinary forks may retain parent metadata without being delegated. ## Consequences Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. -Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. +Continuable children have one runtime root independent of creator residency. The subagent manager owns tree teardown and local membership; the core factory and registry retain only instance creation, publication, and disposal. Third-party callers create managed children through the subagent service rather than assigning ownership to arbitrary factory-created Agents. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 3d4131af01..063f0490bb 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -14,34 +14,34 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 ## 决策 -运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;每个 subagent Activation 保留其确切运行时树根;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 -感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式接收 Agent,并在 Agent 发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 -`SubagentManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 +`SubagentManager.rootOf(agent)` 从已有 Activation 读取运行时成员关系;子级和树根都必须匹配注册表中的存活对象。普通 Session 返回自身,非托管的 subagent header 不授予成员身份。持久化 `parentSession` 描述创建关系;中间创建者离线时,Activation 保留确切树根,同 id 替代实例不能继承它。仅限根级的消费方依据显式 subagent 来源分类。 [作用域参考](../../../../packages/core/scope/README.zh.md)与 [Agent loop 参考](../../../../packages/core/agent-loop/README.zh.md)说明注册和生命周期行为。[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)负责私有调用链理由;显式运行时归属不改变因果发起关系或注册生命周期。 ## 验证 -Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 +托管子级测试验证中间创建者释放,以及子级或树根替换后的确切实例检查。SDK 审批测试验证直接创建者退出后的轮次内路由。Schedule 与用户问题测试拒绝委派 Session,历史普通 fork 则保持独立。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Agent 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 ## 考虑过的替代方案 **保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 -**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;subagent Activation 保留准入时传入的确切树根。 **分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 **使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 -**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录创建祖先关系。中间创建者可以离线,而后代仍由存活树根管理;持久 id 也无法区分替换后的 Agent 实例。历史普通 fork 可能保留父级元数据,但并不属于委派。 ## 后果 生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 -可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 +可续跑子级拥有独立于创建者驻留状态的唯一运行时树根。subagent 管理器拥有整树 teardown 和本地成员关系;核心工厂与注册表仅保留实例创建、发布和释放。第三方调用方通过 subagent 服务创建托管子级,而非给任意工厂创建的 Agent 指定归属。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml index f7082b3940..954230cc2a 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml @@ -9,8 +9,8 @@ en: 4548e7fbdbee5b5a zh: 0078762ea92fa180 /agent-note-consumer-owned-startup-strictness/decision: - en: 1f11e04162e19ad1 - zh: d102e300eba9a2ea + en: 62e93742af5bed66 + zh: 3944d685b6503a2c /agent-note-consumer-owned-startup-strictness/alternatives-considered: en: 1b67e062213184cd zh: 91cc8a015de2bfe4 @@ -18,5 +18,5 @@ en: 283dec1b336c540d zh: 2ed358079072cfff /agent-note-consumer-owned-startup-strictness/testing: - en: c38221069ce98fd3 - zh: 9a2f91f539eba4d4 + en: cee14c22297e5471 + zh: a68748cc36f4a574 diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md index cf17823e72..be4e748975 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -20,6 +20,8 @@ The audit runs only during initial application boot. Later config HMR remains be This policy governs [Web host boot](../../../../packages/boot/app-boot/README.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-09-18-declarative-agent-presets.md) own a separate strict subtree audit. +The browser boot audit applies the same policy to the client plugin tree with its own global list in [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts): the module system, Session control, the renderer, the application frame, the Session view, and its sidebar. Browser entries are named by package, so the list holds package names; a name absent from the boot graph has no effect, and neither list is derived from the other. The list is the minimum fatal set: an entry that a listed entry waits on blocks startup too. Feature plugins, including credential masking, stay optional. A failed entry reports its activation error. Optional failures appear in one console warning, and in Settings → Plugins → Plugin list only after a later graph synchronization reconciles them. Renaming a listed package requires updating the list and its tests. + ## Alternatives considered - **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group. @@ -36,6 +38,8 @@ The compact terminal report keeps the failing plugins visible; a separate file r App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. Unit expectations pin diagnostic grouping, preservation of original error objects and import logs, exporter cleanup, complete diagnostic values, private file creation, concurrent report names, and failed-write fallback. The built Web-profile acceptance asserts a single port-conflict stack without Node wrapper output, verifies the saved diagnostic file and its stderr fallback, serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. +Browser audit unit tests (`boot-client.client.spec.ts`, `boot.client.spec.ts`) cover required and optional import, creation, activation, and pending failures, carrier presentation, and required names absent from the boot graph. Test carriers are stricter than the product: `TestClient` requires every roster entry to activate unless a case passes `allowInactiveEntries`, and the browser e2e lanes treat a `web boot: warning` as a failure with no opt-out. + The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../../../../packages/boot/app-boot/tests/user-patches.spec.ts): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md index c8bda5cb20..4287b4aad2 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -20,6 +20,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles 该策略适用于 [Web host 启动](../../../../packages/boot/app-boot/README.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。 +浏览器启动审计以同一策略审计客户端插件树,使用 [`boot-client.ts`](../../../../packages/client/web/src/boot-client.ts) 中独立的全局名单:模块系统、Session 控制、渲染器、应用框架、Session 视图及其侧栏。浏览器 entry 以包名命名,因此名单写包名;启动图中不存在的名字不产生影响,两份名单互不推导。名单是最小致命集合:已列出 entry 所等待的 entry 同样会阻止启动。功能插件(包括凭据遮挡)保持可选。失败 entry 报告其激活错误。可选失败出现在一条控制台警告中;只有后续图同步调和之后,才会出现在「设置 → 插件 → 插件列表」。重命名名单中的包需要同步更新名单及其测试。 + ## 考虑过的替代方案 - **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。 @@ -36,6 +38,8 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。单元预期输出固定诊断分组、原始错误对象与导入日志的保留、exporter 清理、完整诊断值、私有文件创建、并发报告命名以及写入失败回退行为。构建后的 Web-profile acceptance 断言端口冲突堆栈只输出一次且不包含 Node 包装输出,验证已保存的诊断文件及其 stderr 回退,并会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 +浏览器审计单元测试(`boot-client.client.spec.ts`、`boot.client.spec.ts`)覆盖必需与可选 entry 的导入、创建、激活与 pending 失败、carrier 呈现,以及启动图中不存在的必需名称。测试载体比产品更严格:除非用例传入 `allowInactiveEntries`,`TestClient` 要求每个 roster entry 都激活;浏览器 e2e 把 `web boot: warning` 视为失败,且不提供放宽。 + [Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml index dc596b4fae..5494e130cf 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.i18n.yaml @@ -9,8 +9,8 @@ en: 386864a282e5ad58 zh: a4ea75ec70eb7778 /agent-note-shared-native-cua-process-with-agent-scoped-state/decision: - en: 11bab6870c3d802f - zh: a22114f2217a3a41 + en: 60ec2276ed0bff33 + zh: 5fb48cc787194254 /agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered: en: 883f71387f599160 zh: 4b5f1450be799397 diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md index 8e986aaa2f..ff06f52dc1 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.md @@ -12,7 +12,7 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service. -The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures. +The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Source checkouts can keep grants with the [development signing identity](2026-10-29-cua-dev-signing-identity.md). Installed packages retain their distributed signatures. Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization. diff --git a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md index 371ae5cc3a..2fa2f9b07f 100644 --- a/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-09-cua-shared-native-helper.zh.md @@ -12,7 +12,7 @@ Status: implemented 同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。 -服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。 +服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。源码检出可以用[开发签名身份](2026-10-29-cua-dev-signing-identity.zh.md)保留授权。安装版保留分发时的签名。 每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml index 65205260c3..f1c602359c 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.i18n.yaml @@ -2,18 +2,18 @@ # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md -/agent-note-team-identity-in-required-runtime-context: - en: 8c88759320ae8481 - zh: 4068b18723907eb9 -/agent-note-team-identity-in-required-runtime-context/problem: - en: c9a81cb731ddf7b2 - zh: 428c0e6f8937d7c5 -/agent-note-team-identity-in-required-runtime-context/decision: - en: 6c73479973c1e378 - zh: 98677c77f77f8d81 -/agent-note-team-identity-in-required-runtime-context/alternatives-considered: +/agent-note-subagent-identity-in-required-runtime-context: + en: ce8f378b6e7b57fa + zh: 26bd0415b4dba94a +/agent-note-subagent-identity-in-required-runtime-context/problem: + en: f944254565541454 + zh: 27fa9f67c671dec7 +/agent-note-subagent-identity-in-required-runtime-context/decision: + en: 85100936f97a139c + zh: 614081943944e28b +/agent-note-subagent-identity-in-required-runtime-context/alternatives-considered: en: 774566ffbf9e9db8 zh: ea761d876af462f2 -/agent-note-team-identity-in-required-runtime-context/consequences: - en: 18d1aa67936f1227 - zh: 7e06c1f51553ecdd +/agent-note-subagent-identity-in-required-runtime-context/consequences: + en: 2c01a24cd8cc4556 + zh: 37b5d1ca1173318a diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md index 39794f02d3..fb8e92e811 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md @@ -1,4 +1,4 @@ -# Agent Note: Team identity in required runtime context +# Agent Note: Subagent identity in required runtime context Status: implemented @@ -6,15 +6,15 @@ English | [中文](2026-10-13-team-identity-runtime-context.zh.md) ## Problem -A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect current membership while keeping shared system instructions uniform. +A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect the current subagent tree while keeping shared system instructions uniform. ## Decision -`tool-agent-team` contributes `team:identity` through the Agent-scoped `systemPrompt.context()` registry with `required: true`. Its provider reads the existing Team membership and renders the member's role, name, and addressing instructions. Optional-context suppression cannot remove it. Fixed collaboration policy stays in the system prompt; roster and task state remain tool queries. Initial tasks contain only their supplied content. +`tool-subagent-control` contributes required `subagent:identity` through `systemPrompt.context()` in ordinary subagent and Team compositions. It derives identity from `ctx.subagents.rootOf(agent)`: the root sees its own Session id, and descendants see their own, direct parent, and root ids. An unavailable root is reported explicitly. Optional-context suppression and Team mode do not remove identity. Agents receive messaging and discovery guidance only while the root is available and each corresponding standard tool is admitted. Team task policy stays in the shared system prompt; task state remains a tool query. The loop owns logging, retained-snapshot comparison, and re-admission after compaction. A changed role appends the current full runtime-context snapshot after inherited history; unchanged retained context adds no message. No Team-specific retention state, event type, or log migration is introduced. Existing recorded reminders remain historical messages. -This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity); its roster, mailbox, and task decisions remain active. +This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity). [Task-board wait coordination](2026-10-19-team-task-board-inbox.md) keeps ordinary inbox waiting independent of Team-only model guidance. ## Alternatives considered @@ -28,4 +28,4 @@ This supersedes only the initial-task identity decision in [Agent Teams](../feat Members pay for identity within the full runtime-context snapshot. Other context changes repeat it, while stable retained context adds no new message. Forks preserve inherited history and append current identity; actual provider cache hits remain best-effort. Existing Team authorization remains in tool execution. -Verification covers fresh and fork teammates, an independent fork from a teammate, optional-context suppression, compaction restoration, cold resume, and registration disposal. The recorded `team-targets` scenario pins Lead context; the keyless headless Team workflow pins teammate context through the shipped profile. +Verification covers fresh and fork children, an independent Session fork, optional-context suppression, compaction restoration, unavailable roots, cold resume, and registration disposal. Recorded ordinary subagent and Team workflows pin identity through their shipped compositions; delegated tasks retain final-result return guidance. diff --git a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md index d0e333a71e..f3ebb3e6b3 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 必需 runtime context 中的 Team 身份 +# Agent Note: 必需 runtime context 中的 subagent 身份 Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## Problem -嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 membership,同时保持共享系统指令一致。 +嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 subagent 树,同时保持共享系统指令一致。 ## Decision -`tool-agent-team` 通过 Agent 作用域的 `systemPrompt.context()` 注册 `team:identity`,并设置 `required: true`。提供方读取现有 Team membership,渲染成员角色、名字与寻址说明。关闭可选 context 不能移除它。固定协作策略保留在 system prompt;roster 和 task 状态仍通过工具查询。初始任务只包含传入的正文。 +`tool-subagent-control` 在普通 subagent 和 Team 组合中通过 `systemPrompt.context()` 提供必需的 `subagent:identity`。它从 `ctx.subagents.rootOf(agent)` 派生身份:根看到自身 Session id,后代看到自身、直接父级和根 id。根不可用时会明确报告。可选上下文抑制和 Team 模式都不会移除身份。只有根可用时,agent 才会分别根据对应标准工具的获准情况收到通信与发现指导。Team 任务策略保留在共享系统提示中;任务状态仍通过工具查询。 Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色变化会在继承历史之后追加当前完整 runtime-context 快照;保留的 context 未变化时不追加消息。不引入 Team 专属的保留状态、事件类型或日志迁移。已有身份提醒仍作为历史消息保留。 -本决策仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中的初始任务身份决策;其 roster、mailbox 和 task 决策仍然有效。 +这仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中将身份放入初始任务的决策。[任务板等待协调](2026-10-19-team-task-board-inbox.zh.md)使普通收件箱等待独立于 Team 专用模型指导。 ## Alternatives considered @@ -28,4 +28,4 @@ Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色 成员承担完整 runtime-context 快照中的身份 token 成本。其他 context 变化会重复身份,保留的 context 稳定时则不追加消息。Fork 保留继承历史并追加当前身份;提供方实际缓存命中仍为尽力而为。现有 Team 授权仍在工具执行时落实。 -验证覆盖 fresh 和 fork teammate、从 teammate 创建的独立 fork、关闭可选 context、压缩后恢复、冷恢复和注册释放。录制的 `team-targets` 场景固定 Lead context;无密钥 headless Team 工作流通过内置 profile 固定 teammate context。 +验证覆盖新建和 fork 子级、独立 Session fork、可选上下文抑制、压缩恢复、不可用根、冷恢复和注册释放。录制的普通 subagent 和 Team 工作流通过各自的分发组合固定身份行为;委派任务保留最终结果返回指导。 diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml new file mode 100644 index 0000000000..282acab92a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-15-agent-teams-as-subagents.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md +/agent-note-agent-teams-teammates-are-subagents: + en: 51e2fc51a6e1622a + zh: 7e09ea6292e5921d +/agent-note-agent-teams-teammates-are-subagents/problem: + en: ca2848e3bbc3a009 + zh: 0551e32ff6d187d0 +/agent-note-agent-teams-teammates-are-subagents/decision: + en: 2c29c42cc02a9a05 + zh: bc542e3a27cfd03d +/agent-note-agent-teams-teammates-are-subagents/alternatives-considered: + en: ef0cf648ff54784f + zh: 2a0b3745a54e7072 +/agent-note-agent-teams-teammates-are-subagents/consequences: + en: 3ff718671be076fa + zh: 38a367077ce95d52 +/agent-note-agent-teams-teammates-are-subagents/testing: + en: a0ec8546a783bfd9 + zh: 8920b46970e3a2de diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md new file mode 100644 index 0000000000..8cab7023e0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.md @@ -0,0 +1,41 @@ +# Agent Note: Agent Teams teammates are subagents + +Status: implemented + +English | [中文](2026-10-15-agent-teams-as-subagents.zh.md) + +The adjacency and parent-lifetime restrictions are superseded by [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). [Task-board inbox coordination](2026-10-19-team-task-board-inbox.md) supersedes the separate roster and Team-owned wait; the single subagent identity decision remains active. + +## Problem + +Teammates already use the subagent runtime for creation, Session identity, catalog membership, capacity, cold resume, settlement, and interruption. A separate Team roster duplicates identity, provisioning recovery, and teardown. Team-scoped messaging and control tools shadow the standard subagent tools, forcing profiles to disable ordinary delegation and making fork tool schemas diverge from their parent. + +## Decision + +Use one subagent identity: the Session id. The existing `description` tool argument supplies the catalog label and display text; descriptions may repeat and never address an agent. Creation accepts no member name, reserves no names, and adds no catalog payload version. + +[Tree-wide communication](2026-10-16-tree-wide-agent-communication.md) authorizes an exact live sender within one root tree and preserves the actual sender’s id. Interruption retains ancestor authority and id addressing. + +Every root Agent and its descendants form the Team identified by the root Session id. Team creates no agents and contributes task tools and task policy. Ordinary subagent controls supply required identity, discovery, messaging, interruption, and inbox waiting. Parent and fork children receive identical tool schemas and shared policies. + +Task ownership uses `ownerId`; root reassignment accepts itself or any reachable continuable descendant. Historical roster records remain decode-only. Team persists task changes without ending inbox waits or starting idle turns; [inbox-only waiting](../simplification/2026-10-29-inbox-only-agent-wait.md) owns wake behavior. Historical `team/message/*` records remain readable but are never delivered or acknowledged. + +This supersedes the roster, provisioning, recovery, and messaging decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and the [Team direct inbox decision](../../archived/simplification/2026-09-26-team-direct-inbox.md). The task-board and shared-checkout decisions remain active. [Adjacent Agent messaging](2026-08-27-adjacent-agent-steer-messaging.md) includes sibling routing, while [required runtime-context identity](2026-10-13-team-identity-runtime-context.md) owns the role statement. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md) lists removed tools, methods, and configuration. + +## Alternatives considered + +**Separate Team authority and tools.** This retains a second roster and recovery lifecycle for Agents already owned by the subagent runtime, plus same-name tools and a private delivery adapter. The shared parent relation supplies the required authority. + +**Unique names as sibling-routing opt-in.** Names add validation, reservation, persistence, and ambiguous resolution beside existing Session ids. Descriptions already explain responsibilities; stable ids distinguish repeated descriptions without another identity system. + +**Pluggable address books.** A registry would support groups spanning unrelated parents but add another authorization input. No current consumer needs this; revisit when collaboration must cross parent trees. + +## Consequences + +Tree members discover and message one another through ordinary controls. Team retains task ownership, dependencies, and task policy. Subagent capacity and lifecycle apply; failed starts create no Team roster row. Existing durable task generations remain unchanged. + +Descendants at any depth share the board and can be task owners. Historical members grant no authority. Separate working directories remain explicit caller choices; cross-process task coordination is unsupported. + +## Testing + +Verification covers tree delivery and cold resume with actual sender attribution, task CAS and owner discovery, historical decoding, inbox wait isolation, and registration disposal. Recorded sessions cover Team policy and identity, plus ordinary subagent return guidance. diff --git a/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md new file mode 100644 index 0000000000..8c113d288a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-15-agent-teams-as-subagents.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Agent Teams 队友是子代理 + +Status: implemented + +[English](2026-10-15-agent-teams-as-subagents.md) | 中文 + +邻接和父级生命周期限制由[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)取代。[任务板收件箱协调](2026-10-19-team-task-board-inbox.zh.md)取代独立名册和 Team 所有的等待;单一 subagent 身份决策仍有效。 + +## Problem + +队友已经使用子代理运行时完成创建、Session 身份、目录成员关系、容量、冷恢复、结算和中断。独立 Team 成员列表重复了身份、创建恢复和释放职责。Team 作用域内的消息及控制工具覆盖标准子代理工具,迫使 profile 禁用普通委派,并使分叉工具模式偏离父级。 + +## Decision + +统一使用子代理的 Session id 作为身份。现有 `description` 工具参数提供目录标签和展示文本;描述可以重复,不用于寻址代理。创建不接收成员名称、不预留名称,也不增加目录载荷版本。 + +[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)在同一根树内授权当前在线发送方,并保留实际发送方 id。中断保留祖先权限和 id 寻址。 + +每个根 Agent 及其后代组成以根 Session id 标识的 Team。Team 不创建 agent,提供任务工具和任务策略。普通 subagent 控制工具提供必需身份、发现、通信、中断和收件箱等待。父级和 fork 子级接收相同工具 schema 和共享策略。 + +任务归属使用 `ownerId`;根重新分配可指定自身或任意可达的可继续执行后代。历史名册记录仅供解码。Team 持久化任务变更,不结束收件箱等待,也不启动空闲轮次;[仅等待收件箱](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)负责唤醒行为。历史 `team/message/*` 记录仍可读取,但绝不再投递或确认。 + +本决策取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的成员列表、创建、恢复和消息决策,以及 [Team 直接收件箱决策](../../archived/simplification/2026-09-26-team-direct-inbox.md)。任务板和共享工作树决策仍有效。[相邻 Agent 消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)纳入同级路由,[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责角色声明。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md)列出移除的工具、方法和配置。 + +## Alternatives considered + +**独立 Team 权限和工具。** 这会为子代理运行时已拥有的 Agent 保留第二套成员列表与恢复生命周期,以及同名工具和私有投递适配器。共同父级关系已经提供所需权限。 + +**以唯一名称选择性启用同级路由。** 名称会在现有 Session id 之外增加校验、预留、持久化和歧义解析。描述已能说明职责;稳定 id 可以区分重复描述,无需另一套身份系统。 + +**可插拔地址簿。** 注册表可支持跨不同父级的组,但会增加另一种授权输入。当前没有消费者需要此能力;协作必须跨父级树时再考虑。 + +## Consequences + +树内成员通过普通控制工具发现和联系彼此。Team 保留任务归属、依赖和任务策略。容量和生命周期遵循 subagent 规则;启动失败不会创建 Team 名册行。现有持久化任务代次保持不变。 + +任意深度后代共享任务板,均可成为任务所有者。历史成员不授予权限。独立工作目录仍由调用者显式选择;不支持跨进程任务协调。 + +## Testing + +验证覆盖具有实际发送方归属的整树投递和冷恢复、任务 CAS 与所有者发现、历史解码、收件箱等待隔离和注册释放。录制会话覆盖 Team 策略和身份,以及普通 subagent 返回指导。 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml new file mode 100644 index 0000000000..ee52c30615 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-16-tree-wide-agent-communication.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md +/agent-note-communication-follows-the-subagent-tree: + en: 849d806b901e9f04 + zh: 649b2984f844d510 +/agent-note-communication-follows-the-subagent-tree/problem: + en: c583c3cdb894e417 + zh: 6edc0354b79f4f26 +/agent-note-communication-follows-the-subagent-tree/decision: + en: da35a75e069fa1e4 + zh: 7edf62ad66ab04ec +/agent-note-communication-follows-the-subagent-tree/alternatives-considered: + en: 470f9cf8c3e32af9 + zh: 73bbe29767c5d02a +/agent-note-communication-follows-the-subagent-tree/consequences: + en: 6d409833d762d473 + zh: 000cd93577ec6b34 diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md new file mode 100644 index 0000000000..30dee6781a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.md @@ -0,0 +1,29 @@ +# Agent Note: Communication follows the subagent tree + +Status: implemented + +English | [中文](2026-10-16-tree-wide-agent-communication.zh.md) + +## Problem + +A Team shares one task board across the root's entire subagent tree. Restricting discovery and messages to adjacent relatives forces deeper members to relay work through parents despite sharing tasks. + +## Decision + +`list_agents` starts at the caller's live root and lists that root and all reachable continuable descendants, including the caller. Parent ids and depth remain relative to the root. The existing parent catalogs remain authoritative; no Team directory is stored separately. + +`send_message` accepts another root or continuable member of the same tree. The sender must be the exact live root or a resident continuable subagent. Cross-branch delivery verifies the target descriptor and creation catalogs, then restores only the target under the Team root. Final inbox admission rechecks the sender and root. Cancellation or rejection before acceptance releases the new target instance; accepted input follows normal Agent persistence. + +The existing Agent registry records runtime ownership by the Team root. Creation parent ids and catalogs preserve ancestry for discovery and interrupt authority. A member finishing or being released affects only its own instance; Team-root shutdown joins all startup and cleanup. This removes ancestor restoration, parent retention, and recursive disposal without adding a Team runtime registry. Saved target presets and permission events supply restoration configuration. + +This supersedes the adjacency, discovery, and parent-lifetime restrictions in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their shared inbox, scheduling, attribution, and Team reuse decisions remain active. Members use Session ids for addressing; ordinary Session forks lead independent Teams. + +## Alternatives considered + +**Relay through parents.** This preserves the narrower routing implementation but makes the creation hierarchy determine who can collaborate. Shared work requires direct communication across branches. + +**Separate Team directory and delivery.** A second member list and message path would duplicate the existing catalogs, inbox, and restoration lifecycle. Tree membership supplies the required communication scope without another registry. + +## Consequences + +Discovery output grows with the whole tree. Cold delivery requires one capacity slot and readable creation history. Intermediate creators may be offline. Completion notices reach only online creators, so work that requires descendant results must explicitly wait. Listing is a snapshot and does not promise that a later send succeeds. External and one-shot executions cannot receive follow-ups. diff --git a/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md new file mode 100644 index 0000000000..58d075e9a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-16-tree-wide-agent-communication.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 通信范围由子代理树决定 + +Status: implemented + +[English](2026-10-16-tree-wide-agent-communication.md) | 中文 + +## Problem + +一个 Team 在根的整棵子代理树中共享任务板。把发现和消息限定在相邻成员之间,会迫使深层成员在共享任务的同时,仍通过父级转发工作。 + +## Decision + +`list_agents` 从调用方的在线根开始,列出该根与所有可达的可继续后代,包括调用方。父级 id 和深度始终相对根计算。现有父级目录仍是权威来源;不单独存储 Team 目录。 + +`send_message` 接受同一棵树中的其他根或可继续成员。发送者必须是确切在线根或驻留的可继续子代理。跨分支投递验证目标描述符和创建目录后,仅在 Team 根下恢复目标。最终收件箱准入重新检查发送者与根。接受前的取消或拒绝释放新建目标实例;已接受输入遵循普通 Agent 持久化。 + +现有 Agent 注册表记录 Team 根对运行时实例的所有权。创建父级 id 和目录保留用于发现及中断授权的祖先关系。成员完成或释放只影响自身实例;关闭 Team 根会等待全部启动和清理。这删除了祖先恢复、父级保留和递归释放,无需增加 Team 运行时注册表。目标保存的 preset 和权限事件提供恢复配置。 + +本决策替代[相邻消息](2026-08-27-adjacent-agent-steer-messaging.zh.md)与[子代理队友](2026-10-15-agent-teams-as-subagents.zh.md)中的邻接、发现及父子生命周期限制。它们的共享收件箱、调度、发送者归属和 Team 复用决策仍有效。成员使用 Session id 寻址;普通 Session 分叉领导独立 Team。 + +## Alternatives considered + +**通过父级转发。** 这保留了更窄的路由实现,却让创建层级决定谁能协作。共享工作需要跨分支直接通信。 + +**单独的 Team 目录与投递。** 第二份成员列表和消息路径会重复现有目录、收件箱与恢复生命周期。树成员身份已经提供所需通信范围,无需另一个注册表。 + +## Consequences + +发现输出随整棵树增长。冷投递需要一个容量名额和可读的创建历史。中间创建者可以离线。完成通知仅投递给在线创建者,因此依赖后代结果的工作必须显式等待。列表是快照,不保证后续发送成功。外部执行和一次性执行不能接收后续消息。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml new file mode 100644 index 0000000000..aa751903bf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-team-task-board-inbox.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md +/agent-note-team-task-boards-share-ordinary-subagent-waits: + en: 7f19734715f5d50f + zh: 95af09c9d855d1af +/agent-note-team-task-boards-share-ordinary-subagent-waits/problem: + en: 6fdc766a348d8659 + zh: 8a84748e147deed5 +/agent-note-team-task-boards-share-ordinary-subagent-waits/decision: + en: 154da8a8a5146c89 + zh: 76a7c921153d238d +/agent-note-team-task-boards-share-ordinary-subagent-waits/alternatives-considered: + en: eed6f6d4fa0ccd7b + zh: 61271057d6e5d019 +/agent-note-team-task-boards-share-ordinary-subagent-waits/consequences: + en: cc250eb21dbb45a1 + zh: 9d03d1eb53075fb6 diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md new file mode 100644 index 0000000000..818298684c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md @@ -0,0 +1,31 @@ +# Agent Note: Team task boards share ordinary subagent waits + +Status: implemented + +English | [中文](2026-10-19-team-task-board-inbox.zh.md) + +## Problem + +The subagent tree already supplies identity, discovery, routing, and lifecycle. A second Team roster can grant assignment authority from stale history and duplicates member navigation. Team-wide status waits observe unrelated activity and miss input already pending for the caller. Persisting task-board wakeups as messages adds unread state and message schemas even though the task journal already owns the state to inspect. + +## Decision + +The subagent tree is the Team. Ordinary controls own required identity, communication, discovery, interruption, and `wait_agent`, independently of the optional task board. Team tools own task policy, without adding it to ordinary subagent compositions. Team owns task storage, dependencies, and compare-and-set commands. Assignment accepts the root or any discovered continuable descendant. Historical roster records grant no authority; the Team panel shows tasks and ordinary navigation owns members. + +The wait-signal decision is superseded by [inbox-only Agent waits](../simplification/2026-10-29-inbox-only-agent-wait.md). Tree ownership, task durability, and historical non-authority remain unchanged. + +Task state remains in the existing journal. Ordinary tool results record what the waiting model observes. Historical roster, mailbox, and task generations remain readable and unchanged. This partially supersedes the roster and wait decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md) and [subagent teammates](2026-10-15-agent-teams-as-subagents.md). Their task durability and single-identity rationale remain active; [required runtime identity](2026-10-13-team-identity-runtime-context.md) owns retention. + +## Alternatives considered + +**Keep Team membership and a separate wait tool.** This duplicates tree authority and wait lifecycle, allowing stale roster rows or unrelated activity to affect current work. + +**Persist task-board notices in every inbox.** The task journal already preserves every revision. Durable notices add message types, unread state, coalescing, and delivery-failure recovery solely to interrupt a current wait. + +**Poll Agent status or start every idle task owner.** Activity does not identify useful input, and task readiness does not authorize a new turn. Explicit messages continue idle work when needed. + +## Consequences + +Task completion, inbox availability, current waiting, and Agent lifetime remain separate. Task updates do not end waits; callers inspect current tasks when deciding what to do. The task journal retains committed revisions without storing redundant notifications in recipient Sessions. + +Verification distinguishes pending inbox input from task commits, isolates callers, excludes next-turn input and status changes, and covers wait cleanup and task persistence. Assignment coverage retains arbitrary depth and historical non-authority. diff --git a/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md new file mode 100644 index 0000000000..3a88ce8d19 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Team 任务板复用普通 subagent 等待 + +Status: implemented + +[English](2026-10-19-team-task-board-inbox.md) | 中文 + +## 问题 + +subagent 树已经提供身份、发现、路由和生命周期。第二份 Team 名册可能根据过期历史授予分配权限,并重复成员导航。Team 范围状态等待会观察无关活动,却漏掉调用者已有的待处理输入。任务日志已经拥有待查询状态,将任务板唤醒持久化为消息仍会增加未读状态和消息 schema。 + +## 决策 + +subagent 树就是 Team。普通控制工具独立于可选任务板,负责必需身份、通信、发现、中断和 `wait_agent`。Team 工具负责任务策略,不向普通 subagent 组合添加该策略。Team 负责任务存储、依赖和比较并交换命令。分配接受根或任意已发现的可继续执行后代。历史名册不授予权限;Team 面板显示任务,普通导航负责成员。 + +等待信号决策已由[仅等待收件箱的 Agent 等待](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)取代。树归属、任务持久性和历史记录不授予权限的决策保持不变。 + +任务状态保留在现有日志中。普通工具结果记录等待中的模型所观察到的结果。历史名册、邮箱和任务代次保持可读且不变。这部分取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 和 [subagent 队友](2026-10-15-agent-teams-as-subagents.zh.md) 中的名册与等待决策。其任务持久性和单一身份理由继续生效;[必需运行时身份](2026-10-13-team-identity-runtime-context.zh.md)负责保留规则。 + +## 考虑过的替代方案 + +**保留 Team 成员关系与独立等待工具。** 这会重复树权限和等待生命周期,让过期名册行或无关活动影响当前工作。 + +**将任务板通知持久化到每个收件箱。** 任务日志已保留每个修订。持久通知仅为打断当前等待,就增加消息类型、未读状态、合并和投递失败恢复。 + +**轮询 Agent 状态或启动每个空闲任务所有者。** 活动状态不能标识有用输入,任务就绪也不授权新轮次。需要时通过显式消息继续空闲成员的工作。 + +## 后果 + +任务完成、收件箱可用性、当前等待和 Agent 生命周期保持独立。任务更新不会结束等待;调用者在决定下一步工作时查询当前任务。任务日志保留已提交修订,不在接收者 Session 中存储重复通知。 + +验证区分已有收件箱输入和任务提交,隔离调用者,排除下一轮输入及状态变化,并覆盖等待清理和任务持久性。分配验证保留任意深度和历史记录无权限场景。 diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml new file mode 100644 index 0000000000..afbff73943 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-20-session-team-mode.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-20-session-team-mode.md +/agent-note-team-availability-is-selected-per-session: + en: 5f36d81bd414ea67 + zh: 71883b0fe2afd1b6 +/agent-note-team-availability-is-selected-per-session/problem: + en: db7a81ab452fdd0f + zh: a14fdcdb8ff4cd47 +/agent-note-team-availability-is-selected-per-session/decision: + en: f4e4ba7be0eda796 + zh: 410649e86ff5ca72 +/agent-note-team-availability-is-selected-per-session/alternatives-considered: + en: cd3cc1f3fab84f80 + zh: 6d7792742d62f9fe +/agent-note-team-availability-is-selected-per-session/consequences: + en: b56a6a1c8ad1aa7a + zh: 43bdd8a5bccce8bf diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md new file mode 100644 index 0000000000..2449e1e4d0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.md @@ -0,0 +1,29 @@ +# Agent Note: Team availability is selected per Session + +Status: implemented + +English | [中文](2026-10-20-session-team-mode.zh.md) + +## Problem + +A plugin-level Team switch requires users to change the shared profile before choosing collaboration for one conversation. It can disable another Session’s capability and leaves a second control beside the Session selection. Ordinary tasks still need their existing tool catalog, and disabling collaboration must not discard task history or children. + +## Decision + +The base bundle loads the Team service and task tools, and the Web bundle loads the Team UI. The packages belong to the subagent and client product groups. A saved root-Session selection gates task tools and Team guidance throughout the tree, and the shared board in the root conversation; new Sessions default off. Ordinary subagent creation, communication, and navigation remain available. + +The model and reasoning menu owns the user’s selection. The root conversation’s Subagent panel displays members above shared tasks. Disabling Team keeps the journal and children; re-enabling restores the board. Historical task boards without a saved selection stay enabled. Profile loading removes the retired optional bundle selection, retaining row overrides. + +This supersedes the explicit-composition choice in [Agent Teams](../feature/2026-08-05-agent-teams.md). Its durability, ownership, and lifecycle decisions remain active. [Optional bundles](../process/2026-09-15-shipped-optional-bundles.md) still governs other experimental features. + +## Alternatives considered + +**Keep both switches.** Capability installation remains a prerequisite that the conversation control cannot explain or resolve; users must manage shared profile state for a Session decision. + +**Hide the plugin card without loading Team.** New profiles would have no way to obtain the capability. Loading experimental packages in the default profile would violate product isolation; moving them to their owning product groups makes the delivered support explicit. + +## Consequences + +Default profiles carry the Team implementation while exposing no task tools until enabled. The removed package names require custom compositions to migrate; durable event names and task data remain unchanged. [The upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) owns those steps. + +Verification covers default startup, new-Session opt-in, independent roots, persisted selections, retained tasks, old profile migration, and absence of the plugin switch. Product-isolation checks retain an unrelated experimental plugin as their negative control. diff --git a/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md new file mode 100644 index 0000000000..5b8baec113 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-20-session-team-mode.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 按 Session 选择 Team 能力 + +Status: implemented + +[English](2026-10-20-session-team-mode.md) | 中文 + +## Problem + +插件级 Team 开关要求用户先修改共享 profile,才能为一个会话选择协作。它可能关闭另一个 Session 的能力,并在会话选项之外留下第二个控件。普通任务仍需要原有工具目录,关闭协作也不能丢弃任务历史或子智能体。 + +## Decision + +基础 bundle 加载 Team 服务与任务工具,Web bundle 加载 Team UI。相关包归属 subagent 与 client 产品组。根 Session 保存的选择控制整棵树的任务工具与 Team 指引,以及根会话中的共享任务板;新 Session 默认关闭。普通 subagent 创建、通信与导航继续可用。 + +模型与推理等级菜单承载用户选择。根会话的 Subagent 面板在成员下方显示共享任务。关闭 Team 保留日志与子智能体;重新开启恢复任务板。没有保存选择的历史任务板保持开启。profile 加载时移除已停用的可选 bundle 选择,保留配置行覆盖。 + +本决策替代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 中显式选择组合的部分;其持久性、归属与生命周期决策继续有效。[可选 bundle](../process/2026-09-15-shipped-optional-bundles.zh.md)仍约束其他实验性功能。 + +## Alternatives considered + +**保留两个开关。** 安装能力仍是会话控件无法解释或解决的前置条件;用户必须为 Session 决策管理共享 profile 状态。 + +**隐藏插件卡片但不加载 Team。** 新 profile 将无法获得该能力。默认 profile 加载实验性包会违反产品隔离;将相关包移入所属产品组可明确随产品提供的支持。 + +## Consequences + +默认 profile 包含 Team 实现,但开启前不暴露任务工具。自定义组合必须迁移已移除的包名;持久事件名称与任务数据保持不变。[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)负责具体步骤。 + +验证覆盖默认启动、新 Session 手动开启、独立根、选择持久化、任务保留、旧 profile 迁移和插件开关移除。产品隔离检查使用另一个无关实验性插件作为负对照。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml new file mode 100644 index 0000000000..82eae5a35d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-22-subagent-target-user-controls.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md +/agent-note-user-controls-address-the-target-subagent: + en: d7dcf54ddb311881 + zh: 5f388447737c5414 +/agent-note-user-controls-address-the-target-subagent/problem: + en: 962c75771235903c + zh: 203b651c0830310e +/agent-note-user-controls-address-the-target-subagent/decision: + en: 13a2617e28445744 + zh: 75d193ef7d8c7804 +/agent-note-user-controls-address-the-target-subagent/alternatives-considered: + en: 447adb24280bf2bb + zh: 08d95a93b14aa5c7 +/agent-note-user-controls-address-the-target-subagent/consequences: + en: 98e784f55775247d + zh: 1c62d2ebc71b658f diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md new file mode 100644 index 0000000000..064dbb1e17 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.md @@ -0,0 +1,33 @@ +# Agent Note: User controls address the target subagent + +Status: implemented + +English | [中文](2026-10-22-subagent-target-user-controls.zh.md) + +## Problem + +A browser knows the subagent it is displaying, but requiring its direct creator and a continuable marker makes every control caller repeat history metadata. A caller-supplied parent id checks address consistency without establishing the caller's identity or permission. Calls that resolve only the global access resource also cannot express a grant on one target Session. + +## Decision + +Browser prompts and `interruptByUser` address only the target child Session. Each Remote method explicitly declares that Session as its access resource; the Gateway checks the invocation's principal before attachment admission, restoration, or cancellation. A global or parent Session grant does not imply a child Session grant. Policies decide whether to grant both actions, either action, or inherited access. Same-process user interruption trusts its caller and does not accept a wire-supplied principal. + +Prompt delivery derives the live root from the resident Activation or verified persisted creation edges. Only the cold target resumes; its creator can remain offline. Shared delivery retains exact live root checks, shutdown admission, cancellation, capacity, and final inbox acceptance. A root Session is not a target of the browser subagent prompt endpoint. User interruption addresses a resident Activation without persistence reads or restoration; an absent target remains a no-op, and interruption preserves parked input. + +Model messages retain the exact live sender and same-tree rule from [tree-wide communication](2026-10-16-tree-wide-agent-communication.md). Model interruption retains its stricter ancestor rule. Creation parents still determine catalog edges, history-address consistency, ancestry, and completion recipients; they are not browser control credentials. + +This supersedes the direct-parent addressing requirement for human continuation in [continuable subagents](../feature/2026-07-28-continuable-subagent-conversations.md). That record retains its inbox and durable continuation rationale; tree-wide communication owns its superseded model adjacency and runtime ownership restrictions. No Session format changes. + +## Alternatives considered + +**Keep the parent check as authorization.** The client supplies the parent id and can obtain it from history. Matching that id does not authorize the principal, and requiring it couples controls to discovery metadata. + +**Require a root id from the browser.** The server already derives the runtime owner from the target. Another supplied id adds a consistency check without granting permission; Agent senders still need an independently resolved root to enforce same-tree messaging. + +**Unify user and Agent interruption permissions.** A user with a target grant can stop that target, while an Agent may stop only its descendants. Same-tree collaboration does not grant members control over each other's execution. + +## Consequences + +Remote callers update their prompt fields, interrupt endpoint, and non-owner policy actions and resources; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md) owns the migration. History URLs retain parent ids for consistency checks. Cold host prompts may read creation ancestry twice, once to find the live root and again during restoration; no verified-ancestry cache is retained across asynchronous work. + +Gateway tests cover target grants, global-only and parent-only refusal, separate send and interrupt actions, owner access, and refusal before side effects. Continuation tests cover nested and cold targets with online or offline creators, unavailable roots, and rejection of a root as a subagent target. The Web interrupt scenario exercises the renamed endpoint and parked-input behavior through the shipped composition. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md new file mode 100644 index 0000000000..455a511778 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-target-user-controls.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 用户控制按目标子代理寻址 + +Status: implemented + +[English](2026-10-22-subagent-target-user-controls.md) | 中文 + +## 问题 + +浏览器知道当前展示的子代理,但要求提供直接创建者和 continuable 标记,使每个控制调用方都要重复历史元数据。调用方提供的父级 id 只能检查地址一致性,不能确定调用者身份或权限。仅解析出 global 访问资源的调用也无法表达对单个目标 Session 的授权。 + +## 决策 + +浏览器提示词和 `interruptByUser` 仅按目标子 Session 寻址。每个 Remote 方法显式声明该 Session 为访问资源;Gateway 在附件接纳、恢复或取消之前检查调用的 principal。global 或父 Session 授权不意味着子 Session 授权。策略决定授予两个操作、其中一个操作,或继承访问权。同进程用户中断信任其调用方,不接受从线上传入的 principal。 + +提示词投递从驻留 Activation 或经过验证的持久化创建关系中解析在线根。仅恢复冷目标,创建者可以保持离线。共享投递保留精确在线根检查、关闭准入、取消、容量和最终 inbox 接纳。根 Session 不是浏览器子代理提示词端点的目标。用户中断直接寻址驻留 Activation,不读取持久化数据,也不恢复目标;目标缺席仍为无操作,中断保留停放的输入。 + +模型消息保留[整树通信](2026-10-16-tree-wide-agent-communication.zh.md)中的精确在线发送者和同树规则。模型中断保留更严格的祖先规则。创建父级仍决定目录关系、历史地址一致性、祖先关系和完成通知接收者;它们不是浏览器控制凭证。 + +本决策取代[可继续的 subagent](../feature/2026-07-28-continuable-subagent-conversations.zh.md)对人工继续执行要求直接父级地址的部分。旧记录保留 inbox 和持久化继续执行的理由;整树通信决策拥有其中已取代的模型相邻关系和运行时所有权限制。Session 格式不变。 + +## 考虑过的替代方案 + +**保留父级检查作为授权。** 客户端提供父级 id,也能从历史中取得它。匹配该 id 不能授权 principal,还使控制依赖于发现元数据。 + +**要求浏览器提供根 id。** 服务端已经能从目标解析运行时所有者。再提供一个 id 只会增加一致性检查,不会授予权限;Agent 发送者仍需要独立解析的根来约束同树消息。 + +**统一用户和 Agent 的中断权限。** 用户获得目标授权后可以停止该目标,而 Agent 只能停止自己的后代。同树协作不赋予成员控制彼此执行的权限。 + +## 影响 + +Remote 调用方需要更新提示词字段、中断端点,以及非 owner 策略的操作和资源;迁移步骤由[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md)拥有。历史 URL 保留父级 id 用于一致性检查。冷目标的宿主提示词可能读取两次创建祖先:先查找在线根,再在恢复期间验证;不跨异步操作保留已验证祖先缓存。 + +Gateway 测试覆盖目标授权、仅 global 和仅父级授权的拒绝、独立的发送和中断操作、owner 访问,以及副作用前拒绝。继续执行测试覆盖创建者在线或离线时的嵌套和冷目标、根不可用,以及拒绝把根当作子代理目标。Web 中断场景通过交付组合验证重命名后的端点和输入停放行为。 diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml new file mode 100644 index 0000000000..81682a6981 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-22-subagent-tool-owned-communication.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md +/agent-note-subagent-tools-own-communication-guidance: + en: 0bd031aa81fbc18d + zh: c530e980826698ed +/agent-note-subagent-tools-own-communication-guidance/problem: + en: ac391b569d2bc7e9 + zh: a95f613aa2b330b8 +/agent-note-subagent-tools-own-communication-guidance/decision: + en: 52fb33963bedfe07 + zh: 75fe66d80f47323f +/agent-note-subagent-tools-own-communication-guidance/alternatives-considered: + en: c55d44c6414e0c4c + zh: 9fa2ab31843e259b +/agent-note-subagent-tools-own-communication-guidance/consequences: + en: 52f7234664c515ec + zh: 55ec7b54c044e9af diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md new file mode 100644 index 0000000000..2222cd5dd7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.md @@ -0,0 +1,27 @@ +# Agent Note: Subagent tools own communication guidance + +Status: implemented + +English | [中文](2026-10-22-subagent-tool-owned-communication.zh.md) + +## Problem + +The continuation manager inspected a Symbol on the standard messaging tool before adding tool instructions and parent addressing to the initial task. This coupled orchestration to one tool implementation and made result-delivery guidance depend on tool installation. A published internal entry point carried both that marker and a host prompt adapter used only by tests. + +## Decision + +`tool-subagent-control` owns messaging instructions and parent addressing. Its logged runtime context gives delegated children their direct creator’s id from the Session header, explains when to send findings, and states that sending does not finish a turn. The context is emitted only for delegated children when the plugin’s own tool definition is admitted in the current scope and step; roots and ordinary Session forks receive none. Communication guidance follows inherited history without changing the shared system prompt or tool schema. + +The manager owns result delivery. Local activations with `delivery: 'parent'` receive automatic-result guidance even without messaging tools; caller-owned activations receive no parent-delivery promise. Settlement timing, recipients, output, and suppression of closing text after a final message to the parent remain unchanged. Tool visibility grants no messaging authority. + +This supersedes the Symbol identity and initial-task communication-guidance choices in [adjacent messaging](2026-08-27-adjacent-agent-steer-messaging.md). Its message attribution, fixed Steer delivery, and rejection of a separate report operation remain active. The internal entry point and prompt adapter are removed; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md) owns caller migration. + +## Alternatives considered + +**Keep a cross-package marker or add a service hook.** Tool registration already gives its owner the exact definition, and prompt assembly already supplies the current Agent. Another protocol or stored identity has no independent consumer. + +**Move all return guidance into the tool.** Automatic parent notification depends on activation delivery, not on messaging capability. The tool must not promise it to caller-owned activations or withhold it from children without tools. + +## Consequences + +Tests cover visible, hidden and replaced definitions, logged identity, plugin disposal, fork prefix reuse, and parent versus caller result delivery. Recorded-session snapshots cover the assembled prompt and durable runtime context. Tests needing host input use the existing private manager test helper; no production adapter exists solely to expose that helper. diff --git a/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md new file mode 100644 index 0000000000..21242507b7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-22-subagent-tool-owned-communication.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Subagent 工具拥有通信指导 + +Status: implemented + +[English](2026-10-22-subagent-tool-owned-communication.md) | 中文 + +## 问题 + +继续执行管理器检查标准通信工具上的 Symbol,随后在初始任务中加入工具说明和父级地址。这让编排依赖一个工具实现,也让结果交付指导取决于是否安装工具。已发布的内部入口同时承载该标记和仅供测试使用的宿主 prompt 适配器。 + +## 决策 + +`tool-subagent-control` 拥有通信说明和父级寻址。持久记录的运行时上下文从 Session header 读取委派子级的直接创建者 id,说明何时发送发现,并指出发送不会结束轮次。仅当本插件的工具定义在当前作用域和 step 中可用时,才向委派子级提供上下文;根代理和普通 Session fork 不接收。通信指导位于继承历史之后,不改变共享系统提示词或工具 schema。 + +管理器拥有结果交付。采用 `delivery: 'parent'` 的本地 activation 即使没有通信工具,也会收到自动结果交付指导;调用方拥有的 activation 不会收到父级交付承诺。结算时序、接收方、输出,以及最后向父级发送消息后省略结束文本的规则保持不变。工具可见性不授予通信权限。 + +本决策取代[相邻代理通信](2026-08-27-adjacent-agent-steer-messaging.zh.md)中的 Symbol 身份和初始任务通信指导选择。该记录的消息来源归属、固定 Steer 投递,以及不另设 report 操作的决策继续有效。内部入口和 prompt 适配器已删除;调用方迁移由[升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md)说明。 + +## 考虑过的替代方案 + +**保留跨包标记或增加 service hook。** 工具注册已经向所有者提供确切定义,提示词组装也已提供当前 Agent。另一套协议或身份存储没有独立消费者。 + +**把全部返回指导移入工具。** 自动父级通知取决于 activation 的交付方式,而非通信能力。工具不能向调用方拥有的 activation 承诺自动通知,也不能让没有工具的子级缺少交付说明。 + +## 后果 + +测试覆盖可见、隐藏及替换的定义、持久记录的身份、插件释放、fork 前缀复用,以及父级与调用方结果交付。录制会话快照覆盖组装后的提示词和持久运行时上下文。需要宿主输入的测试使用现有的私有 manager 测试帮助函数;生产代码不再仅为暴露该帮助函数而保留适配器。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml new file mode 100644 index 0000000000..de5a17b10d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-cua-dev-signing-identity.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md +/agent-note-development-signing-identity-for-source-built-cua-helpers: + en: 472c4efd47184a32 + zh: 49f64628531970e9 +/agent-note-development-signing-identity-for-source-built-cua-helpers/problem: + en: a24857a312482724 + zh: dbac75d363670e86 +/agent-note-development-signing-identity-for-source-built-cua-helpers/decision: + en: d2265f9ec5fb7a79 + zh: 05da2479b024a4bb +/agent-note-development-signing-identity-for-source-built-cua-helpers/alternatives-considered: + en: e30db5bc9c7aab43 + zh: fa84883b5527334e +/agent-note-development-signing-identity-for-source-built-cua-helpers/consequences: + en: fb0e8fe44890bdf9 + zh: d9e9ace1a4c10060 diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md new file mode 100644 index 0000000000..5f721474fc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.md @@ -0,0 +1,35 @@ +# Agent Note: Development signing identity for source-built CUA helpers + +Status: implemented + +English | [中文](2026-10-29-cua-dev-signing-identity.zh.md) + +## Problem + +Source checkouts compile `DSH Computer Use.app` with an ad-hoc signature. macOS stores Accessibility and Screen Recording grants for a bundle identifier with the designated requirement of the granted code, and an ad-hoc requirement is the code hash. Every changed build and every other worktree therefore needs new grants, and these builds shared the release helper's bundle identifier and replaced its grant entry. + +## Decision + +The Host builds source checkouts as a development variant with bundle identifier `com.deepseek.harness.computer-use.dev` and name `DSH Computer Use Dev`, in `DSH Computer Use Dev.app`. The macOS privacy list and the helper’s reported launch application use the bundle filename, so that filename also includes Dev. The release variant and its packaging are unchanged. Grants stay with the helper application launched through LaunchServices, as the [shared helper decision](2026-10-09-cua-shared-native-helper.md) requires. + +`sourceSigning` belongs to the macOS provider and accepts only default `ad-hoc` or `{ identity, keychain }` selecting an existing certificate and absolute keychain path. Builds do not create, unlock or remove identities. Developer credentials stay separate from the user-facing DSH home, which stores only the certificate selection; the shared `cua` package owns no macOS signing configuration. + +The [developer signing skill](../../../skills/dsh-cua-macos-signing/SKILL.md) guides Agents through creating, reusing and unlocking an isolated identity under `~/.cua-macos-keys/`, then selecting its fingerprint and keychain path in user configuration. The tool stores a random password in a `0600` file, permits codesign access, and adds the dedicated keychain to the user search list. A file lock serializes setup and removal for that directory; a missing keychain with an existing record is preserved and rejected. A directory marker limits identity management to explicitly claimed, initially empty directories. Unexpected files or symlinked identity entries stop setup and removal before changes, so a developer-selected path cannot erase unrelated contents. + +Certificate signatures embed a signing time even without a timestamp server. Development publication records which generation each unsigned bundle and identity produced, then selects that generation again instead of re-signing, so identical sources keep one executable digest and one shared service. The cache uses certificate fingerprints; explicit certificate names are not reused because renewal can change the certificate behind a name. Signing failures stop publication. Developer setup diagnostics omit passwords and complete command arguments. The build CLI defaults to `--identity -` and accepts an existing certificate with `--identity` and optional `--keychain` for release packaging. + +## Alternatives considered + +**Grant the launching terminal.** Launching the service directly makes macOS attribute permissions to the terminal or editor that started DSH. Every program that application runs, including commands run by the model, then has the permissions. + +**Automatically create a fixed identity during source builds.** This makes ordinary startup create developer private keys and ties their lifetime to user data. An explicit developer workflow preserves the option to reduce repeated grants while keeping identity creation, removal and selection under developer control. + +**Self-signed identity in the login keychain.** The login keychain is already unlocked and on the search list, but allowing `codesign` to use a new key there without a dialog requires the user's login password. + +**Add the keychain to the search list only while signing.** The release packaging script does this for one invocation. Concurrent builds in several worktrees would read and restore the user list over each other, and a stopped build would leave the entry behind anyway. + +**A stable launcher that spawns each worktree's helper.** Grants would follow the launcher, but any local process could ask it to run an arbitrary executable with those grants, which is the same exposure as the stored key without a signature check. + +## Consequences + +Development builds signed by the same certificate share a signing requirement; actual permission retention after grants, rebuilds and worktree changes still requires verification. Other processes of the same user can read the stored password and sign with that identity; the release helper has a different bundle identifier. Deleting DSH home removes its selection, not the developer identity; deleting the identity requires clearing every configuration that references it. The real-signing e2e checks reuse, unlocking, requirements across output directories and search-list restoration after removal. diff --git a/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md new file mode 100644 index 0000000000..9491738cad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-cua-dev-signing-identity.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 源码构建的 CUA helper 使用开发签名身份 + +Status: implemented + +[English](2026-10-29-cua-dev-signing-identity.md) | 中文 + +## 问题 + +源码检出用临时签名编译 `DSH Computer Use.app`。macOS 按 Bundle ID 和被授权代码的指定要求保存辅助功能与屏幕录制授权,临时签名的指定要求就是代码哈希。因此每次构建内容变化、每换一个 worktree,都需要重新授权;这些构建还与正式 helper 共用 Bundle ID,会替换正式版的授权条目。 + +## 决策 + +Host 以开发版构建源码检出,Bundle ID 为 `com.deepseek.harness.computer-use.dev`,名称为 `DSH Computer Use Dev`,应用包文件名为 `DSH Computer Use Dev.app`。macOS 隐私列表和 helper 返回的启动应用名称使用应用包文件名,因此文件名也需包含 Dev。正式版及其打包流程不变。按[共享 helper 决策](2026-10-09-cua-shared-native-helper.zh.md)的要求,授权仍归经 LaunchServices 启动的 helper 应用。 + +`sourceSigning` 由 macOS provider 定义,只接受默认的 `ad-hoc` 和指定已有证书及钥匙串绝对路径的 `{ identity, keychain }`。构建不创建、解锁或移除身份。开发者凭据与面向用户的 DSH home 分开,后者只保存选择证书的配置;共享 `cua` 包不持有 macOS 签名配置。 + +[开发者签名 skill](../../../skills/dsh-cua-macos-signing/SKILL.md)指导 Agent 在 `~/.cua-macos-keys/` 创建、复用和解锁独立身份,将证书指纹和钥匙串路径写入用户配置。工具把随机密码存入 `0600` 文件,允许 codesign 访问私钥,并把专用钥匙串加入用户搜索列表。文件锁让同一目录的初始化和移除串行执行;已有记录但钥匙串缺失时保留记录并报错。目录标记将身份管理限制在明确占用的初始空目录中。发现无关文件或身份文件软链接时,初始化和移除都会在修改前停止,避免开发者指定的路径导致无关内容被删除。 + +即使不使用时间戳服务器,证书签名也包含签名时间。开发构建记录每份未签名内容与身份对应的已签名目录,再次构建相同内容时复用该目录,保持可执行文件摘要和共享服务一致。缓存使用证书指纹;按证书名称选择时每次签名,避免续期后复用旧证书。签名失败会中止发布。开发者工具的诊断不包含密码或完整命令参数;构建 CLI 默认 `--identity -`,支持指定证书,正式打包也可省略 `--keychain`。 + +## 考虑过的替代方案 + +**把权限交给启动终端。** 直接启动服务时,macOS 把权限归属到启动 DSH 的终端或编辑器。该应用运行的所有程序,包括模型执行的命令,都会带上这些权限。 + +**源码构建自动创建固定身份。** 这会让普通启动生成开发者私钥,并把身份生命周期绑定到用户数据目录。显式开发者操作保留了减少重新授权的选择,也让证书的创建、移除和使用范围由开发者决定。 + +**把自签名身份放进登录钥匙串。** 登录钥匙串已解锁且在搜索列表中,但要让 `codesign` 不弹窗地使用其中的新私钥,需要用户的登录密码。 + +**只在签名期间把钥匙串加入搜索列表。** 正式打包脚本对单次调用这样处理。多个 worktree 并发构建时会互相覆盖读取和恢复的列表,构建被中断时条目也会残留。 + +**用固定的启动器运行各 worktree 的 helper。** 授权会跟随启动器,但任何本机进程都能让它带着这些授权运行任意可执行文件,暴露面与保存的私钥相同,还少了签名检查。 + +## 后果 + +同一证书签出的开发构建共用签名要求;授权后重建和跨 worktree 的实际权限保持仍需验证。同一用户的进程可读取保存的密码并使用该身份签名,正式 helper 使用不同的 Bundle ID。删除 DSH home 只移除配置,不删除开发者身份;删除该身份后应清理所有引用它的配置。真实签名 e2e 验证复用、解锁、不同输出目录的签名要求和移除后的搜索列表恢复。 diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml index 8b8bba1309..bbc63b3c3b 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.i18n.yaml @@ -12,8 +12,8 @@ en: 47b44ed9313c2ccf zh: 7ad254f8ef88351d /agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/alternatives-considered: - en: d52fa1af379dea5b - zh: 4e404994f7a607dc + en: 3153c1e7563a3357 + zh: 9841db2b0156075b /agent-note-subagent-end-turn-guidance-stays-in-the-delegation-paragraph/verification: en: be076216381ea64d zh: 2da8d9819c20e2fe diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md index cd800e94fe..8a25a23102 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.md @@ -29,7 +29,7 @@ The sentence stays one literal line. Rewording `the next result starts a new tur **Move the guidance into the tool description.** The paragraph is assembled from the set of visible delegation tools and lists their names; the tool description describes one tool. Moving the sentence changes ownership without changing the behavior it prevents. -**Restore every prompt phrase reverted together with this sentence.** The read guidance and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph. +**Restore every prompt phrase reverted together with this sentence.** The read guidance ([2026-10-26 note](2026-10-26-read-guidance-line-ranges.md)) and the `run_code` argument order are separate decisions with their own evidence; this note records only the delegation paragraph. ## Verification diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md index b11656473a..ba1c5cbc6a 100644 --- a/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-10-21-subagent-end-turn-guidance.zh.md @@ -29,7 +29,7 @@ Status: implemented **把说明移进工具描述。** 这段指导按可见委派工具集合组装并列出工具名,工具描述只描述单个工具。移动这句会改变归属,但不改变它阻止的行为。 -**把与这句一起被回退的所有提示词都恢复。** read 指导与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。 +**把与这句一起被回退的所有提示词都恢复。** read 指导([2026-10-26 note](2026-10-26-read-guidance-line-ranges.zh.md))与 `run_code` 参数顺序是各自有证据的独立决定;本记录只覆盖委派段。 ## 验证 diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml new file mode 100644 index 0000000000..86e21bee13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-26-read-guidance-line-ranges.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md +/agent-note-read-guidance-names-shell-readers-and-line-ranges: + en: 1926710614598539 + zh: b0106d4a6a2d648d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/problem: + en: 4189d516419d8d12 + zh: 30d8496064dd44e3 +/agent-note-read-guidance-names-shell-readers-and-line-ranges/decision: + en: 34f18307f7ad87af + zh: 21c466c6ce6c598d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/alternatives-considered: + en: aea7ffb1159ab81a + zh: 4842ca79dc30654d +/agent-note-read-guidance-names-shell-readers-and-line-ranges/verification: + en: cbc16bfba4abe862 + zh: 9a71e6c020c01f69 +/agent-note-read-guidance-names-shell-readers-and-line-ranges/consequences: + en: cf55161ea197a7a9 + zh: fa10ca413f8fd726 diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md new file mode 100644 index 0000000000..60ad77abee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.md @@ -0,0 +1,35 @@ +# Agent Note: Read guidance names shell readers and line ranges + +Status: implemented + +English | [中文](2026-10-26-read-guidance-line-ranges.zh.md) + +## Problem + +The read guidance named only `cat` as the shell command the read tool replaces, and described `offset`/`limit` as continuing a large file rather than reading a line range. #6057 recorded a long Session reading ranges with `sed -n X,Yp` 60 times. #6054 added `sed`, `head`, `tail`, and the line-range wording; a later prompt revert removed them together with unrelated changes. + +The 2026-10-23 evaluation of the SDK profile (native tools, six benchmarks) measured this sentence as the only change: Pass@1 mean −0.03pp and Pass@3 mean +0.70pp over the five benchmarks that report Pass@3 (ProgramBench-New has none) versus the base commit, with mixed step and token deltas (ProgramBench-New −15.5 steps, Terminal-Bench-v4 +13.5 steps). The PTC mode runs did not include this variant; the section renders in every request where `read` is visible. + +## Decision + +Restore the #6054 wording, byte-identical: `Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` The read tool and its guidance are owned by `@deepseek-ai/dsh-tool-fs`; write and edit guidance are unchanged. + +## Alternatives considered + +**Rely on the read tool schema.** The schema already documents `offset` and `limit`, but #6057 shows the model chose `sed` while that schema existed; the guidance is the only model-visible place that names `sed`, `head`, and `tail`. + +**Restore every prompt phrase reverted together with this sentence.** The subagent end-turn sentence, the `run_code` argument order, the PTC syntax ownership, and the SDK persona are separate decisions; the first is already restored ([subagent end-turn note](2026-10-21-subagent-end-turn-guidance.md)), and the others carry their own evidence. + +**Say "line range" without naming shell commands.** The observed failure was choosing shell commands, so the sentence names them. + +## Verification + +- `packages/fs/tool-fs/tests/tools.spec.ts` asserts the rendered guidance text. +- Every recorded `system-prompt*.expected.md` sidecar that renders the read guidance carries the restored sentence: `test:snapshot` replays the session and SDK set, and `pnpm run test:web:ci` replays the `snapshots/web` set (the `node 24 / snapshots and artifacts` lane runs both). Two pinned historical sidecars (`web/cordis-tool-round`, `web/schedule-catalog`) retain their recorded text. +- The evaluation above measured this single change on the native SDK profile. + +## Consequences + +- Every request with the read tool carries three more command names and a line-range clause, about 10 tokens. +- The model is directed to the read tool for line ranges instead of `cat`, `sed`, `head`, or `tail`. +- The benchmark measured this sentence as neutral; keeping it rests on the #6057 production observation, not on a measured quality gain. diff --git a/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md new file mode 100644 index 0000000000..25487ea733 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-26-read-guidance-line-ranges.zh.md @@ -0,0 +1,35 @@ +# Agent Note: read 指导点名 shell 读取命令与行区间 + +Status: implemented + +[English](2026-10-26-read-guidance-line-ranges.md) | 中文 + +## 问题 + +read 指导只把 `cat` 点名为 read 工具替代的 shell 命令,并把 `offset`/`limit` 描述成继续读大文件,而不是读行区间。#6057 记录了一个长 Session 用 `sed -n X,Yp` 读行区间 60 次。#6054 增加了 `sed`、`head`、`tail` 和行区间措辞;后来的一次提示词回退把它们和不相关的改动一起删掉了。 + +2026-10-23 对 SDK profile(native 工具、六个 benchmark)的评测里,这句是唯一改动:相对 base commit,Pass@1 均值 −0.03pp、Pass@3 均值 +0.70pp(该均值取报告 Pass@3 的五个 benchmark;ProgramBench-New 没有 Pass@3),步数与 token 变化方向不一(ProgramBench-New −15.5 步、Terminal-Bench-v4 +13.5 步)。PTC 模式的运行没有包含这个变体;只要 `read` 可见,这段就会进入每个请求。 + +## 决策 + +逐字恢复 #6054 的措辞:`Use the read tool — not shell commands like cat, sed, head, or tail — to inspect text files. Use offset and limit to read a line range or continue a large file.` read 工具及其指导归 `@deepseek-ai/dsh-tool-fs` 所有;write 与 edit 的指导不变。 + +## 考虑过的替代方案 + +**只依赖 read 工具 schema。** schema 已经写明 `offset` 与 `limit`,但 #6057 显示在那个 schema 存在时模型仍选择 `sed`;指导是唯一点名 `sed`、`head`、`tail` 的模型可见位置。 + +**把与这句一起被回退的所有提示词都恢复。** subagent 结束本轮句、`run_code` 参数顺序、PTC 语法归属、SDK persona 是各自独立的决定;第一项已经恢复([subagent 结束本轮 note](2026-10-21-subagent-end-turn-guidance.zh.md)),其余各有自己的证据。 + +**只说“行区间”,不点名 shell 命令。** 观测到的失败就是模型选择 shell 命令,所以这句点名它们。 + +## 验证 + +- `packages/fs/tool-fs/tests/tools.spec.ts` 断言渲染出的指导文本。 +- 所有渲染 read 指导的 `system-prompt*.expected.md` sidecar 都带有恢复后的句子:`test:snapshot` 回放 session 与 SDK 集合,`pnpm run test:web:ci` 回放 `snapshots/web` 集合(`node 24 / snapshots and artifacts` lane 两条都跑)。两个显式 pin 的历史 sidecar(`web/cordis-tool-round`、`web/schedule-catalog`)保留录制文本。 +- 上面的评测在 native SDK profile 上只改了这一句进行测量。 + +## 影响 + +- 只要 read 工具存在,每个请求多三个命令名和一句行区间说明,约 10 token。 +- 模型读行区间时被引导到 read 工具,而不是 `cat`、`sed`、`head`、`tail`。 +- benchmark 上这句测得为中性;保留它依据的是 #6057 的生产观测,而不是测到的质量收益。 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 6566e1a57e..ef8bb48089 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -9,8 +9,8 @@ en: a72071eeedb77402 zh: 818d16ce429fd610 /agent-note-continuable-subagents/decision: - en: 3420195435e02a8f - zh: 6156eedb3c23d360 + en: 6ba1bf6184794aa8 + zh: 71b4e41556dafb93 /agent-note-continuable-subagents/decision/materialization-and-public-operations: en: c3a83ff748a02143 zh: 468bf62c8ddabf70 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index e76aae22fe..f5d178fec7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -18,6 +18,8 @@ Parent Agents need to send later work to the same live child without changing it ## Decision +Human control addressing and permissions are superseded by [target Session user controls](../architecture/2026-10-22-subagent-target-user-controls.md); model-message adjacency and parent-owned runtime restrictions are superseded by [tree-wide communication](../architecture/2026-10-16-tree-wide-agent-communication.md). The direct-parent authorization statements below do not govern those current operations. + The shared activation entry point, external execution ownership, and caller-versus-parent result delivery are governed by [Unified subagent activations](../simplification/2026-09-17-unified-subagent-activations.md). This record retains the independent rationale described below. A continuable subagent has one durable Session and at most one process-local Activation: diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index ce543b4efa..44f51e1d9e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -18,6 +18,8 @@ parent Agent 还需要在不改变当前轮次的前提下,向同一个在线 ## 决策 +人工控制的地址和权限由[目标 Session 用户控制](../architecture/2026-10-22-subagent-target-user-controls.zh.md)取代;模型消息的相邻关系限制和父级运行时所有权限制由[整树通信](../architecture/2026-10-16-tree-wide-agent-communication.zh.md)取代。下文的直接父级授权表述不约束这些当前操作。 + 共享 activation 入口、外部执行所有权,以及调用方与父级之间的结果投递选择由[统一 subagent activation](../simplification/2026-09-17-unified-subagent-activations.zh.md) 决策拥有。本记录保留下述独立理由。 一个可继续 subagent 拥有一个持久化会话,并且至多拥有一个进程内激活: diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index 68ef489fe0..dd2778d632 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -6,35 +6,35 @@ en: c914d8148bfc4bf9 zh: a8115f87c0ac6cae /agent-note-durable-agent-teams-over-continuable-children/problem: - en: bab62798c2dc07b4 - zh: fe42c5abf9127d37 + en: 567b68ab8eb4b5b2 + zh: 31a40aa22821d63a /agent-note-durable-agent-teams-over-continuable-children/decision: - en: 35650a30529c01f8 - zh: 4f9ee01b568d2fed + en: affeddb8788ea73b + zh: 73615d3ccbd71871 /agent-note-durable-agent-teams-over-continuable-children/profile-delegation: - en: 4835fb8c0c0d8a0d - zh: 5f9438a0d58104c4 + en: f4c8d8760c7ebcf4 + zh: 32ccca202a7769ac /agent-note-durable-agent-teams-over-continuable-children/team-identity: - en: f82812d6ac0ac78d - zh: 7dd80a6ad13674ca + en: d79f4425f2c1929f + zh: 6586410db4a02cd1 /agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery: - en: 4345ac9a6ac7ad04 - zh: 1d19ecbb128a75a3 + en: 624e9d42af1af859 + zh: 020dcbb26211ea67 /agent-note-durable-agent-teams-over-continuable-children/mailbox-and-task-transactions: - en: bfc3063536837c0e - zh: 6b8e69ba85cc2bb1 + en: bfa07b5bc6354360 + zh: c82119c0a5ea7f01 /agent-note-durable-agent-teams-over-continuable-children/shared-checkout-boundary: - en: 34920da68b5763e0 - zh: 7604392594e4b8a8 + en: 384f75638b0f1fb6 + zh: b7ab1191784d3d27 /agent-note-durable-agent-teams-over-continuable-children/web-projection: - en: e6348054f024725b - zh: 8740636e99940308 + en: 1dc1386f5033e83b + zh: 533803da51f3716f /agent-note-durable-agent-teams-over-continuable-children/alternatives-considered: - en: 8cb2fd8839f5f8b7 - zh: e302ca03e569de65 + en: 087ffae4744a129b + zh: 842dc1ded7f8d9c3 /agent-note-durable-agent-teams-over-continuable-children/testing: - en: 092ac3c2b6f552aa - zh: c688c1bec5ad8a07 + en: 2ff7adaaaa65fad7 + zh: 9c7dd682abe988d1 /agent-note-durable-agent-teams-over-continuable-children/consequences: - en: da9cd22b1185a793 - zh: e20d7ef9226f237f + en: c071fa2ccdb911b5 + zh: 3b8cf0d3303a4d40 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index b93e2716f9..2056882e73 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -10,47 +10,41 @@ The subagent seam supplies fresh/fork providers, durable child Sessions, FIFO fo All same-process Agents also share one checkout. Filesystem edit tools can reject an observed stale version, but Bash, formatters, generators, and external writers bypass that fence. Treating a teammate name or task owner as a file lock would hide rather than solve this concurrency boundary. -Agent Teams needs an explicit opt-in composition while its public contracts remain experimental. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. +Team collaboration requires explicit opt-in; [Session selection](../architecture/2026-10-20-session-team-mode.md) owns its granularity and default composition. The default tool catalog and simple-task behavior must remain unchanged, while an explicitly requested Team must survive child Activation settlement and mailbox delivery races long enough for the Lead to aggregate the result before process teardown. ## Decision -Every ordinary runtime root is the implicit Lead of a Team identified by that root's `SessionId`. The Team has no creation event: its Lead pseudo-row exists by identity, while durable state begins with the first member, message, or task event. A roster is flat and contains at most the configured number of immutable lowercase-kebab-case names. Each teammate is a continuable direct child with a reserved Session id; only the Lead creates or interrupts teammates. Ordinary provider-owned subagents outside the roster are not Team members, and an ordinary fork is a new root whose inherited Team records are excluded by their ancestor `TeamId`. +Every ordinary root and its descendants form the Team identified by the root `SessionId`; durable task state begins with the first task event. [Subagent teammates](../architecture/2026-10-15-agent-teams-as-subagents.md) owns the single agent identity. [Inbox-only waiting](../simplification/2026-10-29-inbox-only-agent-wait.md) keeps task-board changes independent of inbox waiting. An ordinary Session fork leads a new tree and excludes inherited Team authority. -The implementation is split into `@deepseek-ai/dsh-experimental-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-experimental-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. Deployments mount both plugins explicitly and may disable legacy continuable controls with the same model-visible names. The shared policy announces Team availability and describes coordination responsibilities. Both packages are public members of `packages/experimental/`; the [experimental package reference](../../../../packages/experimental/AGENTS.md) owns publication, dependency isolation, and promotion. +The implementation is split into `@deepseek-ai/dsh-agent-team`, which owns `ctx.agentTeams` and durable semantics, and `@deepseek-ai/dsh-tool-agent-team`, which owns scoped schemas and model guidance. Every Team tool declares its complete result schema and renders that value as compact JSON, so the compiler checks each `execute` against what the model is promised and no result spends tokens on indentation. The base bundle mounts both plugins; Team registers no tool that shares a name with the subagent controls. The shared policy announces Team availability and describes coordination responsibilities. Both packages belong to the [subagent family](../../../../packages/subagent/README.md); the root Session’s saved selection gates the task tools and policy. The [Session-mode upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) describes profile migration. The Lead must wait for required work before its final answer. Process teardown remains the final lifecycle owner and drains continuation Activations; a Team task owner is durable state and is not automatically released by idle, interruption, or process exit. ## Profile delegation -The [Team profile](../../../../packages/experimental/agent-team-profile/README.md) disables `subagent` and `subagent_fork` together with the overlapping global controls. Direct model delegation uses `spawn_teammate` with fresh or fork context, keeping those children in the durable roster. Workflow remains available through the base profile’s fresh `spawn` provider for scripted orchestration; it cannot inherit a teammate’s conversation identity through the model tool. The Subagent services and providers remain shared infrastructure. Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork’s own role. +The [base bundle](../../../../packages/bundle/base/README.md) inserts the Team rows and keeps the base delegation and control tools; teammates are started with those tools as described in [subagents](../architecture/2026-10-15-agent-teams-as-subagents.md). Ordinary Session forks retain their history; [current identity handling](../architecture/2026-10-13-team-identity-runtime-context.md) supplies the fork's own role. ## Team identity -Shared system policy and all tool schemas stay uniform across members; execution owns role restrictions. Team tools resolve the caller’s Team and accept member names, so the model needs no Team id. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. +Shared system policy and tool schemas stay uniform across members; execution enforces role restrictions. Team tools resolve the caller’s Team and use Session ids as task owners. [Required runtime-context identity](../architecture/2026-10-13-team-identity-runtime-context.md) owns identity injection, retention, and fork handling. ## Provisioning and recovery -Creation first appends and flushes a `team/member` provisioning snapshot in the Lead Session, then starts the reserved continuable child through the selected fresh or fork provider. Failure before initial inbox acceptance appends a failed snapshot. Success flushes the child's accepted inbox item before appending active. Recovery recognizes that initial message while it is still pending or after it enters user-message history. Names are reserved by the first provisioning record and never reused, including after failure. Disposal closes admission, aborts and awaits admitted creation and send operations, then stops every live child recorded by the roster; a failed child remains cleanup-owned until its Activation exits, and cleanup rejection fails disposal. - -A root recovery reconciles an unterminated provisioning record against the child's independently persisted Session. Matching direct-parent and continuable descriptors plus a recorded initial user message prove successful admission and produce active; absence, corruption, mismatched provider/lineage, or a missing admitted message produces failed. The creator re-reads the terminal phase under the same Lead-log serializer; if recovery marked failed while creation succeeded, it drains the child and reports a provisioning conflict instead of retaining an orphan. This avoids reconstructing an initial prompt that was never retained in the Team log and contains plugin-reload races. - -Fresh children have no inherited conversation. Fork children capture the Lead's completed-turn prefix once and retain it as their own durable seed. The current delegation turn remains excluded, matching the existing fork provider contract. +Teammate creation and cold resume belong to the subagent runtime ([subagent teammates](../architecture/2026-10-15-agent-teams-as-subagents.md)). Historical `team/member` records remain readable with their phases and descriptions; Team writes no new membership records. ## Mailbox and task transactions -The historical Lead-log mailbox remains a readable format: queued and delivered records keep their stable identity and sender, and Team payload validation still applies to them. Team no longer delivers or acknowledges them; new sends follow the [direct inbox decision](../simplification/2026-09-26-team-direct-inbox.md). +The historical Lead-log mailbox remains readable in original Session logs. The [task-only projection decision](../simplification/2026-10-29-team-task-only-projection.md) supersedes mailbox projection, checkpoint retention, and Team payload validation. Historical queued messages are never delivered or acknowledged. -Unknown mailbox content stays as decoded JSON during event projection and checkpoint admission. Validation inspects its type without rebuilding its fields, because generic object parsing can omit a valid own `__proto__` key. Locally declared content variants undergo structural validation; opaque plugin fields do not gain Team semantics. Cache invalidation is necessary because parser correction alone cannot recover keys omitted from cached state. The [Team documentation](../../../../packages/experimental/agent-team/README.md) owns the restore behavior. - -`send_message` uses Steer for running, idle, and cold targets. New sends return inbox acceptance. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) records the single-tool scheduling rationale. +Member messages use the subagent `send_message` operation, which steers running, idle, and cold targets and returns inbox acceptance. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. -`wait_agent` blocks on one roster, mailbox, task, or live-status edge registered after the call starts instead of encouraging model polling. It does not replay an earlier edge, so callers re-read authoritative state after wakeup or timeout. Lead-only interruption cancels the current turn with inbox preservation and does not alter mailbox or task ownership. +`wait_agent` belongs to ordinary subagent controls and waits for the exact caller’s next-step inbox, including already-pending input. Task commits do not end waits, deliver messages, or start idle turns. Agents send messages when another member needs to act on a task change. Ancestors may interrupt descendants without changing task ownership. ## Shared checkout boundary -Members start in the Lead’s current directory and observe shared filesystem writes immediately. Later directory changes stay local to each Session. The policy tells members to partition tasks, record advisory write scopes, order dependent work, and let the Lead inspect the final diff and run tests. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. +Members start in the Lead’s current directory and observe shared filesystem writes immediately. Later directory changes stay local to each Session. The task policy tells members to claim ready tasks, use advisory write scopes and dependencies, and complete tasks after doing the work. A filesystem stale-version rejection requires rereading and rebasing the intended change. No equivalent guarantee is claimed for Bash, formatters, code generation, or direct external writes. The Team domain does not infer branches, create worktrees, merge changes, or silently change cwd. A deployment or caller can use [explicit worktree creation](2026-09-13-explicit-worktree-creation.md) separately. This preserves the existing same-world subagent and sandbox contracts. @@ -58,36 +52,36 @@ The Team domain does not infer branches, create worktrees, merge changes, or sil The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data. -The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it. +The task panel uses non-deleted task views with readiness and overlap diagnostics, plus `failure` beside the last valid state. Historical roster fields remain decodable but have no current UI or assignment authority. Ordinary subagent navigation owns member discovery; task owner labels reuse Session and catalog metadata. -Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. +Publication requires `apply` to return a new state per applied event and replace only the touched collection. Mailbox-only events retain the client view and publish nothing. The existing carrier sends each changed complete view to connected browsers; the package README records that cost. ## Alternatives considered -**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. +**Keep a separate Team communication authority.** The later [tree-wide communication decision](../architecture/2026-10-16-tree-wide-agent-communication.md) places routing and authorization in the subagent runtime; a task board cannot grant another route. -**Store mail in each target Session before Lead-log enqueue.** Rejected because target materialization and acceptance can fail after the Team has committed the send. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary. +**Keep a durable Team mailbox.** Target admission and task commit are independent operations. The historical Team mailbox remains readable, while current messaging uses the target inbox. Task commits neither deliver messages nor end waits. **Treat task ownership or write scopes as locks.** Rejected because external writers bypass them, crashed owners remain durable, and path-prefix overlap cannot prove semantic independence. False mutual exclusion is more dangerous than an explicit warning. **Create isolated worktrees automatically.** Rejected because worktree creation, branch naming, merge policy, ignored files, build artifacts, and cleanup are deployment choices. It also changes the same-world behavior existing subagents and sandboxes expose. -**Enable Teams in the default catalog.** Rejected because scoped Team controls would shadow same-named legacy globals and unsolicited delegation would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team and disables the legacy controls without adding Team packages to shipped dependency graphs. +**Enable Teams in the default catalog.** Rejected because unsolicited coordination and Team tool schemas would add latency and token cost to simple tasks. An opt-in profile bundle inserts Team without adding Team packages to shipped dependency graphs. -**Use an in-memory board and mailbox.** Roster and task state must survive settlement, HMR, and interruption. Historical mailbox records stay readable; message ownership follows the target inbox. +**Use an in-memory board and mailbox.** Task state must survive settlement, HMR, and interruption. Historical mailbox records stay readable; message ownership follows the target inbox. -**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every roster, task, and receipt. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. +**Return Team tool results as untyped JSON.** Rejected because an undeclared result type lets `execute` drift from the value the model is promised without a compiler error, and it invites indentation that costs tokens on every task and wait result. Each Team tool therefore declares its complete result schema and one shared helper renders it compactly. ## Testing -Package tests cover Team authority, provisioning durability, task transitions, direct inbox acceptance, sender attribution, lifecycle cancellation, and historical mailbox readability. The keyless Team profile snapshot pins the policy, tools, and workflow; the CLI composition test checks persisted Team and child logs. +Package tests cover task transitions, tree-based assignment, historical roster and mailbox decoding, and task commits that leave inbox waits pending. Ordinary subagent tests cover inbox waiting; recorded Team compositions cover identity, task policy, and tools. -Model-facing member identities and availability follow the [tool projection reference](../../../../packages/experimental/tool-agent-team/README.md); service residency and durable identity remain distinct. +Identity and inbox waiting follow [subagent controls](../../../../packages/subagent/tool-subagent-control/README.md); task policy follows the [Team tool reference](../../../../packages/team/tool-agent-team/README.md). ## Consequences -The Lead Session retains whole task/member snapshots and historical mailbox records. This favors inspectable recovery over compact deltas; deleted and delivered history remains append-only until broader Session retention applies. +The Lead Session retains whole task snapshots and historical member and mailbox records. This favors inspectable recovery over compact deltas; deleted and delivered history remains append-only until broader Session retention applies. -An active roster member can be non-resident, so `inactive` is not failure and sends can incur cold-resume latency. Admission failures reject new sends. Historical pending mail is never delivered. Failed provisioning permanently consumes its name and member slot. +A teammate can be non-resident, so `inactive` is not failure and sends can incur cold-resume latency. Historical pending mail is never delivered. Coordination reduces likely checkout conflicts but cannot eliminate writes outside filesystem compare-and-set tools. The final diff and tests remain the Lead's integration boundary. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index f38c6f68c4..3fbb92daf3 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -10,47 +10,41 @@ subagent seam 已提供 fresh/fork provider、持久 child Session、FIFO foll 同进程 Agent 还共享一个 checkout。文件系统 edit 工具可以拒绝已观察到的陈旧版本,但 Bash、formatter、generator 与外部 writer 会绕过该屏障。把 teammate name 或 task owner 当作文件锁只会掩盖而不是解决该并发边界。 -Agent Teams 的公开约定仍处于实验阶段,因此需要显式启用的组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 +Team 协作需要显式开启;[Session 选择](../architecture/2026-10-20-session-team-mode.zh.md)负责选择粒度与默认组合。默认工具目录与简单任务行为必须保持不变;而显式请求的 Team 必须能跨越 child Activation settlement 与 mailbox 投递竞争,使 Lead 在进程 teardown 前汇总结果。 ## Decision -每个普通运行时 Root 都是一个隐式 Team 的 Lead,Team id 等于该 Root 的 `SessionId`。Team 没有 creation event:Lead pseudo-row 由身份直接存在,持久状态从第一条 member、message 或 task event 开始。roster 是扁平结构,最多包含配置数量、不可变且采用小写 kebab-case 的名字。每个 teammate 都是使用预留 Session id 的 continuable 直接 child;只有 Lead 可以创建或 interrupt teammate。roster 外由 provider 管理的普通 subagent 不是 Team member;普通 fork 是新的 Root,继承的 Team 记录会因 ancestor `TeamId` 被排除。 +每个普通根及其后代组成以根 `SessionId` 标识的 Team;持久任务状态从首个任务事件开始。[subagent 队友](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)负责单一 agent 身份。[仅等待收件箱](../simplification/2026-10-29-inbox-only-agent-wait.zh.md)使任务板变更独立于收件箱等待。普通 Session fork 成为一棵新树的根,并排除继承的 Team 权限。 -实现拆分为 `@deepseek-ai/dsh-experimental-agent-team` 与 `@deepseek-ai/dsh-experimental-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。部署显式挂载两个插件,并可禁用具有相同模型可见名称的旧 continuable control。共享策略声明 Team 可用,并说明协调责任。两个包都是 `packages/experimental/` 的公开成员;[实验性包参考](../../../../packages/experimental/AGENTS.md)负责发布、依赖隔离与 promotion。 +实现拆分为 `@deepseek-ai/dsh-agent-team` 与 `@deepseek-ai/dsh-tool-agent-team`:前者负责 `ctx.agentTeams` 和持久语义,后者负责 scoped schema 与模型指引。每个 Team 工具都声明完整的结果 schema,并把该值渲染为紧凑 JSON,因此编译器会检查每个 `execute` 是否符合对模型的承诺,也没有结果把 token 花在缩进上。基础 bundle 挂载两个插件;Team 不注册与 subagent control 同名的工具。共享策略声明 Team 可用,并说明协调责任。两个包属于 [subagent 家族](../../../../packages/subagent/README.zh.md);根 Session 保存的选择控制任务工具与策略。[Session 模式升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)说明 profile 迁移。 Lead 必须等待所需工作后才能给出最终答案。进程 teardown 仍是最终生命周期 owner,并会 drain continuation Activation;Team task owner 是持久状态,不会因 idle、interrupt 或进程退出自动释放。 ## Profile delegation -[Team profile](../../../../packages/experimental/agent-team-profile/README.zh.md) 禁用 `subagent`、`subagent_fork` 以及名称重叠的全局控件。模型直接委派使用支持 fresh 或 fork 上下文的 `spawn_teammate`,使这些子代理进入持久 roster。Workflow 仍通过 base profile 的 fresh `spawn` 提供方执行脚本编排;经模型工具创建的 workflow 子代理不会继承 teammate 的对话身份。Subagent 服务和提供方仍是共享基础设施。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 +[基础 bundle](../../../../packages/bundle/base/README.zh.md) 插入 Team 行,并保留 base 的委派与控制工具;teammate 按[subagent](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)所述使用这些工具启动。普通 Session fork 保留历史;[当前身份处理](../architecture/2026-10-13-team-identity-runtime-context.zh.md)提供 fork 自己的角色。 ## Team identity -共享 system 策略和全部工具 schema 在成员间保持一致;执行时检查角色权限。Team 工具根据调用者确定 Team,并接受成员名字,因此模型不需要 Team id。[必需 runtime-context 身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留与 fork 处理。 +成员共享统一系统策略和工具模式;执行时强制角色权限。Team 工具解析调用方所属 Team,并使用 Session id 作为任务负责人。[必需运行时身份](../architecture/2026-10-13-team-identity-runtime-context.zh.md)负责身份注入、保留和分叉处理。 ## Provisioning and recovery -创建操作先在 Lead Session 中追加并 flush `team/member` provisioning 快照,再通过选定 fresh 或 fork provider 启动预留的 continuable child。初始 inbox 获准前的失败会追加 failed 快照;成功会先 flush child 中已接受的 inbox 条目,再追加 active。恢复会在初始消息仍处于 pending 或已进入用户消息历史时识别它。名字由第一条 provisioning 记录永久保留,包括失败后也不能复用。dispose 会关闭准入,中止并等待已获准的创建与发送操作,再停止 roster 记录的所有 live child;failed child 在 Activation 退出前仍由 cleanup 拥有,cleanup 拒绝会让 dispose 失败。 - -Root 恢复时会把未终结 provisioning 记录与独立持久 child Session 对账。直接 parent 与 continuable descriptor 匹配,并且已经记录初始用户消息,才能证明准入成功并转为 active;缺失、损坏、provider/lineage 不匹配或缺少已准入消息都会转为 failed。creator 会在同一 Lead 日志 serializer 内重读终态;如果 recovery 在创建成功时先标记 failed,creator 会 drain child 并报告 provisioning conflict,而不是遗留孤儿。这样既无需重建从未保存在 Team 日志中的初始 prompt,也能约束插件 reload 竞争。 - -fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前缀,并保留为自己的持久 seed。当前 delegation turn 保持排除,与既有 fork provider 契约一致。 +队友创建和冷恢复属于子代理运行时([子代理队友](../architecture/2026-10-15-agent-teams-as-subagents.zh.md))。历史 `team/member` 记录保留阶段和描述并继续可读;Team 不写入新成员记录。 ## Mailbox and task transactions -历史 Lead-log mailbox 仍是可读格式:queued 与 delivered 记录保留稳定身份与发送者,Team payload 校验仍适用于它们。Team 不再投递或确认这些记录;新发送遵循[直接 inbox 决策](../simplification/2026-09-26-team-direct-inbox.zh.md)。 +历史 Lead-log mailbox 仍可从原始 Session 日志读取。[仅投影任务的决策](../simplification/2026-10-29-team-task-only-projection.zh.md)取代邮箱投影、检查点保留和 Team 载荷校验。历史排队消息绝不再投递或确认。 -事件投影和 checkpoint 准入期间,未知 mailbox 内容保持为已解码 JSON。校验只检查其 type,不重建字段,因为通用对象解析可能省略有效的自有 `__proto__` 键。本地声明的内容变体接受结构校验;不透明的插件字段不获得 Team 语义。缓存失效是必要的,因为只修正解析器无法恢复缓存状态中省略的键。[Team 文档](../../../../packages/experimental/agent-team/README.zh.md)拥有恢复行为的说明。 - -`send_message` 对 running、idle 和 cold target 使用 Steer。新发送返回 inbox 接收结果。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)记录单一工具调度的理由。 +member 消息使用 subagent 的 `send_message` 操作,它对 running、idle 和 cold target 使用 Steer,并返回 inbox 接收结果。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 -`wait_agent` 等待调用注册后发生的下一条 roster、mailbox、task 或实时 status 边,避免模型轮询。它不会回放更早的边,因此调用方需要在唤醒或超时后重新读取权威状态。仅限 Lead 的 interrupt 使用 inbox preservation 取消当前 turn,不改变 mailbox 或 task owner。 +`wait_agent` 属于普通 subagent 控制工具,等待当前调用者的下一步收件箱,包括已有待处理输入。任务提交不结束等待、不投递消息,也不启动空闲轮次。需要另一个成员处理任务变更时,Agent 应发送消息。祖先可中断后代,不改变任务归属。 ## Shared checkout boundary -所有 member 创建时使用 Lead 的当前目录,并立即观察共享文件系统写入。后续目录变更仅影响各自 Session。策略要求 member 切分任务、记录提示性 write scope、为有序工作添加依赖,并由 Lead 检查最终 diff 和运行测试。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 +所有 member 创建时使用 Lead 的当前目录,并立即观察共享文件系统写入。后续目录变更仅影响各自 Session。任务策略要求 member 认领就绪任务、使用提示性 write scope 和依赖,并在完成工作后完成任务。文件系统 stale-version 拒绝后必须重新读取并 rebase 修改意图。Bash、formatter、codegen 与直接外部写入不具备等价保证。 Team 领域不会推断 branch、创建 worktree、merge 变更或静默改变 cwd。deployment 或调用方可以另外使用[显式工作树创建](2026-09-13-explicit-worktree-creation.zh.md)。这样保留既有 same-world subagent 与 sandbox 契约。 @@ -58,36 +52,36 @@ Team 领域不会推断 branch、创建 worktree、merge 变更或静默改变 c Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。 -传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 +任务面板使用包含就绪和重叠诊断的未删除任务视图,以及最后有效状态旁的 `failure`。历史名册字段仍可解码,但不拥有当前 UI 或分配权限。普通 subagent 导航负责成员发现;任务所有者标签复用 Session 和目录元数据。 -发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 +发布要求 `apply` 为每个应用的事件返回新状态,且只替换触及的集合。仅邮箱事件保留客户端视图,不发布更新。现有载体向已连接浏览器发送变更后的完整视图;包 README 记录该成本。 ## Alternatives considered -**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 +**保留独立 Team 通信权限。** 后续的[整树通信决策](../architecture/2026-10-16-tree-wide-agent-communication.zh.md)将路由和授权置于 subagent 运行时;任务板不能授予另一条路由。 -**在 Lead 日志入队前把 mail 存入每个 target Session。** 拒绝,因为 Team 提交发送之后,target materialization 与准入仍可能失败。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。 +**保留持久化 Team 邮箱。** 目标接纳和任务提交是独立操作。历史 Team 邮箱保持可读,当前通信使用目标收件箱。任务提交既不投递消息,也不结束等待。 **把 task ownership 或 write scope 当作锁。** 拒绝,因为外部 writer 会绕过它们,崩溃 owner 会持久保留,而路径前缀重叠不能证明语义独立。虚假的互斥保证比明确 warning 更危险。 **自动创建隔离 worktree。** 拒绝,因为 worktree 创建、branch 命名、merge 策略、ignored file、构建产物与 cleanup 都是 deployment 选择;它也会改变既有 subagent 与 sandbox 暴露的 same-world 行为。 -**在默认工具目录中启用 Team。** 拒绝,因为 scoped Team control 会覆盖同名旧全局工具,主动 delegation 也会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team 并禁用旧 control,同时不向随附依赖图添加 Team 包。 +**在默认工具目录中启用 Team。** 拒绝,因为主动协调与 Team 工具 schema 会给简单任务增加延迟和 token 成本。opt-in profile bundle 会插入 Team,同时不向随附依赖图添加 Team 包。 -**使用内存 board 与 mailbox。** roster 与任务状态必须经受 settlement、HMR 与中断。历史 mailbox 记录保持可读;消息归属目标 inbox。 +**使用内存 board 与 mailbox。** 任务状态必须经受 settlement、HMR 与中断。历史 mailbox 记录保持可读;消息归属目标 inbox。 -**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 roster、task 与回执上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 +**让 Team 工具返回未类型化 JSON。** 拒绝,因为未声明的结果类型会让 `execute` 在没有编译错误的情况下偏离对模型的承诺,也会引入在每份 task 与 wait 结果上都消耗 token 的缩进。因此每个 Team 工具都声明完整的结果 schema,并由一个共享 helper 紧凑渲染。 ## Testing -包测试覆盖 Team 权限、provisioning 持久性、任务转换、直接 inbox 接收、发送者归属、生命周期取消和历史 mailbox 可读性。无密钥 Team profile 快照固定 policy、工具与工作流;CLI 组合测试检查持久 Team 和 child 日志。 +包测试覆盖任务转换、基于树的分配、历史名册和邮箱解码,以及任务提交后收件箱等待保持未完成。普通 subagent 测试覆盖收件箱等待;录制 Team 组合覆盖身份、任务策略和工具。 -模型可见的成员身份与可用状态遵循[工具投影说明](../../../../packages/experimental/tool-agent-team/README.zh.md);服务驻留状态和持久身份仍保持区分。 +身份和收件箱等待遵循 [subagent 控制工具](../../../../packages/subagent/tool-subagent-control/README.zh.md);任务策略遵循 [Team 工具参考](../../../../packages/team/tool-agent-team/README.zh.md)。 ## Consequences -Lead Session 保留完整 task/member 快照和历史 mailbox 记录。这优先支持可检查的恢复,而不是紧凑增量;deleted 与 delivered 历史在更广泛的 Session 保留策略生效前仍只追加。 +Lead Session 保留完整 task 快照以及历史 member 与 mailbox 记录。这优先支持可检查的恢复,而不是紧凑增量;deleted 与 delivered 历史在更广泛的 Session 保留策略生效前仍只追加。 -active roster 成员可能不驻留,因此 `inactive` 不表示失败,发送可能产生冷恢复延迟。准入失败会拒绝新发送。历史 pending 邮件永远不会被投递。provisioning 失败永久占用其名称和成员槽位。 +teammate 可能不驻留,因此 `inactive` 不表示失败,发送可能产生冷恢复延迟。历史 pending 邮件永远不会被投递。 协调可以降低 checkout 冲突概率,但无法消除文件系统 CAS 工具之外的写入。最终 diff 与测试仍是 Lead 的集成边界。 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml index fc9f88a600..75d71a51fc 100644 --- a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.i18n.yaml @@ -19,7 +19,7 @@ zh: 993378f78f5a4120 /agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers: en: 0b9303333bf482ae - zh: 6dfc68ad9e2924c3 + zh: 8a2ffe660bbe8730 /agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility: en: 754705367c350b31 zh: e10b6541458c4a45 diff --git a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md index 871eb3fe91..c5437202b5 100644 --- a/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md @@ -32,7 +32,7 @@ owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服 每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话: -- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。 +- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以触碰授权范围之外的文件,而其嵌套调用仍保留同样的守卫与路径检查;分享对话框会就此向所有者提示。 - **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。 沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。 diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml index bf652c02d6..db15a4f23d 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml @@ -9,8 +9,8 @@ en: 7926983089aebab2 zh: 366e32db00674a83 /agent-note-archiving-a-session-with-running-work/decision: - en: 23adfbe72d671d6c - zh: 6fec976acfa54720 + en: 703d8b6f837cc894 + zh: aabe24d201ca0d81 /agent-note-archiving-a-session-with-running-work/alternatives-considered: en: 708268e1fe4b4b75 zh: 64a8f6d6f333e090 diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md index 4bc3bab644..fcaba90418 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md @@ -14,8 +14,8 @@ Archive never hides running work. The registry owns the rule and declares one ca - **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is. - **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive. -- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. -- **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. +- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime reports and cancels running descendants through each Activation’s verified ancestry, with live header ancestry for unmanaged Agents (any depth, never a fork). Releasing a creator does not erase its descendants’ membership. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. +- **Archived means no model step.** The API Session Controller checks archived Sessions and available header ancestry in `agent/pre-step`. The Subagent runtime checks each managed Agent’s retained creation ancestors in the same event; intermediate creators may be offline. Both reject the step, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. - **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md index c7a912fa65..c511810b1d 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md @@ -14,8 +14,8 @@ - **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。 - **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。 -- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 -- **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 +- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 通过各 Activation 已验证的祖先关系报告并取消运行中的后代,非托管 Agent 使用在线 header 血缘(任意深度,从不包括 fork)。释放创建者不会抹去后代的归属。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 +- **已归档即不跑模型步。** API Session Controller 在 `agent/pre-step` 中检查已归档 Session 和可用的 header 血缘。Subagent runtime 在同一事件中检查每个托管 Agent 保留的创建祖先;中间创建者可以离线。两者都会拒绝该步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 - **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml new file mode 100644 index 0000000000..f4bb734afe --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-02-liveshare-plugin-method-catalog.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md +/agent-note-liveshare-plugin-method-catalog: + en: 1daaf32c7ea8d3cd + zh: 70a94350263d22c9 +/agent-note-liveshare-plugin-method-catalog/problem: + en: 2122ac5fdd75f900 + zh: dc135aaa0d1dddc0 +/agent-note-liveshare-plugin-method-catalog/decision: + en: 6e0b897ba6460894 + zh: f05866ccde01a5af +/agent-note-liveshare-plugin-method-catalog/alternatives-considered: + en: 57896311cada7804 + zh: 01fbd1cbd05353ef +/agent-note-liveshare-plugin-method-catalog/consequences: + en: e2069a5f9801f767 + zh: 0698f0b9e1e49201 diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md new file mode 100644 index 0000000000..699c33bbe5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.md @@ -0,0 +1,23 @@ +# Agent Note: LiveShare plugin method catalog + +Status: implemented + +English | [中文](2026-11-02-liveshare-plugin-method-catalog.zh.md) + +## Problem + +LiveShare used to expose plugin endpoints through a declaration API. The API let a plugin name arbitrary endpoints, so a catalog mistake could widen a viewer's reach without a corresponding Gateway check. The replacement needs one source of truth that says which plugin methods a share may expose. + +## Decision + +The catalog is enumerated from the Typert local registry at runtime. One entry is emitted per bundle, using the bundle's localized title when available and the component package title otherwise. Endpoint descriptions come from the registered Remote descriptors. A share stores only method-level policies: the Host intersects each policy with the catalog before the Gateway dispatches a call. + +The catalog excludes the DSH core bundles. Their methods remain governed by the existing Session and Workspace sharing roles, not by plugin permissions. The core bundle names are derived from `PROFILE_TEMPLATES`, so the exclusion follows the shipped profile composition instead of a second hand-maintained list. LiveShare's own bundle is excluded because it is the sharing implementation, not a capability a share can grant itself. + +## Alternatives considered + +Keeping the declaration API would leave two sources of truth: a plugin-declared endpoint and the Gateway's own classification. A hand-maintained allowlist in LiveShare would drift from the shipped profile templates. Deriving the exclusion from the actual profile templates keeps the catalog aligned with composition without a second list. + +## Consequences + +A method that is not in the Typert local registry cannot be selected or called through plugin permissions. A plugin that uses an owner-only exact route is absent from the catalog; the viewer sees an `owner-route-not-shared` notice when it calls that route. Adding a shareable method is therefore a plugin-side registration decision, not a LiveShare catalog edit. diff --git a/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md new file mode 100644 index 0000000000..4f0f639dc4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-02-liveshare-plugin-method-catalog.zh.md @@ -0,0 +1,23 @@ +# Agent Note:LiveShare 插件方法目录 + +Status: implemented + +[English](2026-11-02-liveshare-plugin-method-catalog.md) | 中文 + +## 问题 + +LiveShare 过去通过声明 API 暴露插件端点。该 API 允许插件声明任意端点,因此目录写错时,viewer 的可达范围会在没有对应 Gateway 校验的情况下被扩大。替代方案需要一个唯一事实来源,明确一条分享可以暴露哪些插件方法。 + +## 决定 + +目录在运行时从 Typert 本地注册表枚举。每个 bundle 生成一行,优先使用 bundle 的本地化标题,没有时使用组件包标题;端点说明来自已注册的 Remote descriptor。分享记录只保存方法级策略,Host 在 Gateway 派发前把策略与目录取交集。 + +目录排除 DSH 核心 bundle。它们的方法仍由原有的 Session 和 Workspace 分享角色控制,而不是插件权限。核心 bundle 名从 `PROFILE_TEMPLATES` 派生,使排除规则跟随实际下发的 profile 组合,而不是再维护一份手写名单。LiveShare 自身的 bundle 也被排除,因为它是分享实现,不是分享可以授予自己的能力。 + +## 备选方案 + +保留声明 API 会留下两个事实来源:插件声明的端点和 Gateway 自己的分类。由 LiveShare 维护手写白名单又会与实际下发的 profile 模板漂移。从真实 profile 模板派生排除规则,让目录跟随组合而不需要第二份名单。 + +## 影响 + +不在 Typert 本地注册表中的方法无法被插件权限选中或调用。使用 owner-only exact route 的插件不会出现在目录中;viewer 调用该路由时会看到 `owner-route-not-shared` 提示。因此新增可分享方法是插件侧的注册决定,而不是修改 LiveShare 目录。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index 9e538f26c6..970eaf9557 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -9,8 +9,8 @@ en: dc43ba84345e1405 zh: dbc03572ab7710d2 /agent-note-ship-optional-bundles-with-the-installation/decision: - en: 2f20e356517715a6 - zh: 35c7ff8c4cd8a712 + en: 0f3e0daee65b6510 + zh: 2e9c8000da75fb6c /agent-note-ship-optional-bundles-with-the-installation/alternatives-considered: en: 95b48b61757f1f13 zh: e9fb2d6ea492a4bc diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 74f824fc88..aaa055f7b5 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -14,7 +14,6 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/util/official-bundles/src/bu Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -The [single-bundle reference](../../../../packages/experimental/agent-team-profile/README.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy; the [optional-bundle decision for experimental capabilities](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md) owns composition and display metadata; `OPTIONAL_BUNDLES` owns the current shipped list. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index 129f76c454..6135f08763 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -14,7 +14,6 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -[单组合包参考](../../../../packages/experimental/agent-team-profile/README.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略;[实验能力的可选 bundle 决策](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)定义组合与显示元数据;当前随附名单由 `OPTIONAL_BUNDLES` 管理。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml deleted file mode 100644 index f830391e86..0000000000 --- a/.agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-26-team-direct-inbox.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-26-team-direct-inbox.md -/agent-note-team-messages-use-the-target-inbox: - en: ae17b789652784b5 - zh: fa462ace95c8cdaa -/agent-note-team-messages-use-the-target-inbox/problem: - en: 83296699f94c77d3 - zh: e681a426bf0a703a -/agent-note-team-messages-use-the-target-inbox/decision: - en: 8d5fed34f19c7302 - zh: 25417793d3797775 -/agent-note-team-messages-use-the-target-inbox/alternatives-considered: - en: e847556d8e9f1071 - zh: bf1abe2f0e201eac -/agent-note-team-messages-use-the-target-inbox/consequences: - en: 24085acb09438dc8 - zh: 27c92e8de8cf7dbe diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml new file mode 100644 index 0000000000..3c03e11100 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-inbox-only-agent-wait.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md +/agent-note-inbox-only-agent-waits: + en: 8a451f9706af8dea + zh: 9818b5abdee12a9c +/agent-note-inbox-only-agent-waits/problem: + en: 7a6e28745bda3305 + zh: e862166572cb9a9f +/agent-note-inbox-only-agent-waits/decision: + en: 553a1e166dd7e7e9 + zh: c05f16577fa6753c +/agent-note-inbox-only-agent-waits/alternatives-considered: + en: 64a44ad3884a90c3 + zh: 1fffe0c6fd78acbe +/agent-note-inbox-only-agent-waits/consequences: + en: 45b6d456c3ca226c + zh: e9ebacd4b6bcaeca diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md new file mode 100644 index 0000000000..80e46f63e6 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.md @@ -0,0 +1,31 @@ +# Agent Note: Inbox-only Agent waits + +Status: implemented + +English | [中文](2026-10-29-inbox-only-agent-wait.zh.md) + +## Problem + +A task-board commit changes shared state but does not deliver input to a member. Ending every member's wait on each edit couples ordinary subagent controls to Team coordination and makes callers resume without a message explaining what needs attention. + +## Decision + +`wait_agent` observes only its exact caller's next-step inbox through the existing insertion event. It subscribes before checking pending input, consumes nothing, and removes listeners and timers on input, timeout, cancellation, caller disposal, or tool unload. Queued next-turn input, other Agents' activity, and task-board changes do not end the wait. + +Task commands append and flush their revisions without notifying waits or writing inbox messages. A caller sends an explicit message when a member needs to act on an update. No Agent method, shared waiting service, or task-specific waiting event is introduced. + +This supersedes only the wait-signal decision in [Team task boards](../architecture/2026-10-19-team-task-board-inbox.md). Its tree authority, durable task storage, historical non-authority, and separation from ordinary controls remain applicable. No Session event generation changes. + +## Alternatives considered + +**Notify all current waits after every task commit.** This avoids duplicate inbox state but still makes unrelated task edits interrupt every waiting member. Moving the notification behind a service method retains that behavior and coupling. + +**Persist each task change as a message.** The task journal already preserves revisions; per-member notices add unread state and delivery recovery. Explicit messages communicate which updates require action. + +**Add waiting to Agent or its inbox.** Existing input events and tool-owned cancellation already implement the required wait without extending the core API. + +## Consequences + +Task updates alone do not resume a waiting or idle member. Callers must communicate when work should continue. The tool returns no task contents; members query the board when needed. Task changes before or during a wait have the same effect: none. + +Unit tests cover pending input, caller isolation, concurrent waits, cancellation, disposal, and task commits that leave waits pending. The recorded task-board scenario checks past and concurrent commits against timed-out waits, while completion notification remains ordinary inbox input. diff --git a/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md new file mode 100644 index 0000000000..6bf2e39562 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-inbox-only-agent-wait.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 仅等待收件箱的 Agent 等待 + +Status: implemented + +[English](2026-10-29-inbox-only-agent-wait.md) | 中文 + +## 问题 + +任务板提交改变共享状态,但不向成员投递输入。每次编辑都结束所有成员的等待,会使普通 subagent 控制依赖 Team 协调,并让调用者在没有消息说明需要关注什么时恢复执行。 + +## 决策 + +`wait_agent` 仅通过现有插入事件观察当前调用者自己的下一步收件箱。它先订阅再检查待处理输入,不消费输入,并在输入到达、超时、取消、调用者释放或工具卸载时移除监听器和定时器。排队的下一轮输入、其他 Agent 的活动和任务板变化不会结束等待。 + +任务命令追加并刷盘修订,不通知等待者,也不写入收件箱消息。需要成员处理更新时,调用者发送明确消息。不新增 Agent 方法、共享等待服务或任务专用等待事件。 + +这仅取代[Team 任务板](../architecture/2026-10-19-team-task-board-inbox.zh.md)中的等待信号决策。树权限、持久任务存储、历史记录不授予权限及与普通控制工具分离的决策仍然适用。不改变 Session 事件代次。 + +## 考虑过的替代方案 + +**每次任务提交后通知所有当前等待。** 这避免了重复收件箱状态,但仍让无关任务编辑打断每个等待成员。将通知移到服务方法后仍保留该行为和耦合。 + +**将每次任务变化持久化为消息。** 任务日志已经保留修订;逐成员通知增加未读状态和投递恢复。明确消息可以表达哪些更新需要行动。 + +**在 Agent 或收件箱中增加等待。** 现有输入事件和工具拥有的取消机制已能实现所需等待,无须扩展核心 API。 + +## 后果 + +仅更新任务不会恢复等待中或空闲的成员。需要继续工作时,调用者必须通信。工具不返回任务内容;成员按需查询任务板。等待前和等待期间发生的任务变化效果相同:都不影响等待。 + +单元测试覆盖已有输入、调用者隔离、并行等待、取消、释放,以及不结束等待的任务提交。任务板录制场景检查过去和并发提交下的等待超时,完成通知仍作为普通收件箱输入。 diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml new file mode 100644 index 0000000000..cf5fb34bc3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-29-team-task-only-projection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md +/agent-note-team-projection-retains-only-tasks-and-mode: + en: 030f2abb8b5137fe + zh: e661455f374c8b1f +/agent-note-team-projection-retains-only-tasks-and-mode/problem: + en: 43bd1c0daacd2b86 + zh: a35efb4e84cd9288 +/agent-note-team-projection-retains-only-tasks-and-mode/decision: + en: dd21640eb965f332 + zh: 345bc4df573cbadf +/agent-note-team-projection-retains-only-tasks-and-mode/alternatives-considered: + en: 96a0f421f9f92746 + zh: 314f4557f391f90d +/agent-note-team-projection-retains-only-tasks-and-mode/consequences: + en: 1c1b767b7905869f + zh: 1241351bfc433b70 diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md new file mode 100644 index 0000000000..57a85f4105 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.md @@ -0,0 +1,29 @@ +# Agent Note: Team projection retains only tasks and mode + +Status: implemented + +English | [中文](2026-10-29-team-task-only-projection.zh.md) + +## Problem + +The Team projection retained historical member, message, and delivery collections after subagent discovery and messaging replaced their runtime consumers. Its retired lifecycle and mailbox validators could still fail the current task board, while checkpoints retained message bodies with no current reader. + +## Decision + +Project only tasks, saved mode, and their recovery metadata. Ignore `team/member` and `team/message/*` payloads without rebuilding or validating their former state machines. This deliberately gives up historical teammate and mailbox state recovery and the associated validation failures. The user accepted that loss; task recovery remains required. + +Checkpoint version 7 invalidates prior state, including failures caused by retired records. The registry rebuilds from the unchanged Session log. Historical event declarations, format migrations, message sources, and attachment export remain supported; no Session format bump or log conversion is needed. + +This partially supersedes the projection and mailbox-validation decisions in [Agent Teams](../feature/2026-08-05-agent-teams.md). The [subagent identity](../architecture/2026-10-15-agent-teams-as-subagents.md) and [inbox-only wait](2026-10-29-inbox-only-agent-wait.md) decisions remain active, as do task durability and shared-checkout semantics. + +## Alternatives considered + +**Retain decode-only roster and mailbox projections.** No production consumer reads these collections. Keeping them preserves message history in checkpoints and lets obsolete validation stop current task operations. + +**Migrate or delete historical log events.** The task board can ignore these records directly. Rewriting them adds migration work and would discard archival data that existing format readers and exports can still use. + +## Consequences + +Old teammate and mailbox state is unavailable through Team projection. Original log records remain readable, but neither grant authority nor trigger delivery. Reintroducing recovery requires a concrete consumer and an explicit decision about its retained data and failure scope. + +Tests cover ignored legacy payloads, version-6 checkpoint invalidation, current checkpoint suffix replay, JSONL restart with task mutation, stable view references, and a recorded headless profile accepting a task after retired lifecycle and mailbox records. Task and mode validation still fail visibly. diff --git a/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md new file mode 100644 index 0000000000..221abe771b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-10-29-team-task-only-projection.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Team 投影仅保留任务与模式 + +Status: implemented + +[English](2026-10-29-team-task-only-projection.md) | 中文 + +## Problem + +子代理发现和通信替代运行时消费者后,Team 投影仍保留历史成员、消息与投递集合。已退役的生命周期和邮箱校验器仍可能使当前任务板失败,而检查点保留着没有当前读取方的消息正文。 + +## Decision + +仅投影任务、保存的模式及其恢复元数据。忽略 `team/member` 和 `team/message/*` 载荷,不再重建或校验原有状态机。这明确放弃历史队友与邮箱状态恢复,以及相应的校验失败行为。用户接受这一能力损失;任务恢复仍为必要能力。 + +检查点版本 7 使旧状态失效,包括退役记录导致的失败。注册表从未改写的 Session 日志重建状态。历史事件声明、格式迁移、消息来源和附件导出仍受支持;无需提升 Session 格式版本或转换日志。 + +本决策部分取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 的投影与邮箱校验决策。[子代理身份](../architecture/2026-10-15-agent-teams-as-subagents.zh.md)和[仅等待收件箱](2026-10-29-inbox-only-agent-wait.zh.md)决策仍有效,任务持久性与共享检出语义也保持有效。 + +## Alternatives considered + +**保留仅供解码的名册与邮箱投影。** 没有生产消费者读取这些集合。保留它们会在检查点中保存消息历史,并允许过时校验阻断当前任务操作。 + +**迁移或删除历史日志事件。** 任务板可以直接忽略这些记录。改写增加迁移工作,还会丢失现有格式读取器和导出功能仍可使用的归档数据。 + +## Consequences + +Team 投影不再提供旧队友与邮箱状态。原始日志记录仍可读取,但不授予权限,也不触发投递。重新引入恢复需要具体消费者,并明确决定保留的数据和失败影响范围。 + +测试覆盖忽略旧载荷、版本 6 检查点失效、当前检查点后缀重放、JSONL 重启后修改任务、稳定视图引用,以及录制的 headless profile 在退役生命周期与邮箱记录之后接受任务。任务和模式校验仍可见地报错。 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index 0fa00ec1bf..778be9ae01 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: 37faaffb4662fd54 - zh: df0a4bf016393be9 + en: 3ceb80f455e9a47d + zh: 89d8bd11b42d31b0 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 559a8037caa9e3aa - zh: 4f3719ed09445cc2 + en: b5bfb4cfa231ce0f + zh: 631b9f2978241bc1 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index c3c361047f..7c92d9c72e 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Absolute time budgets use each side's minimum sample mean, with the mean process CPU time from that sample. Doubling checks take the median of the five within-round ratios, keeping the adjacent samples paired through aggregation. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,7 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered -- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. +- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the sample counts, GC inclusion, workloads, and limits. Independent minima can still select rounds at different speeds; the median of paired ratios prevents one faster half-sized sample from deciding the scaling result. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. - **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index 7541efa2de..f5b31146b8 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。绝对耗时预算取两侧样本均值的最小值,并保留对应样本的平均进程 CPU 时间。翻倍检查取五个同轮次耗时比的中位数,使相邻样本在汇总时仍保持配对。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,7 +54,7 @@ Status: implemented ## 考虑过的替代方案 -- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 +- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留样本数、GC 计入规则、负载与上限。独立选取最小值仍可能选中运行速度不同的轮次;配对比值的中位数避免单个更快的小输入样本决定翻倍结果。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 - **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 diff --git a/.agents/skills/dsh-cua-macos-signing/SKILL.md b/.agents/skills/dsh-cua-macos-signing/SKILL.md new file mode 100644 index 0000000000..6d28147f29 --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/SKILL.md @@ -0,0 +1,78 @@ +--- +name: dsh-cua-macos-signing +description: Proactively recommend a personal fixed signing key to macOS DSH developers using Computer Use from source, rebuilding the helper, or switching worktrees, even before they ask about repeated permissions. After they choose it, generate or reuse the key outside DSH home and configure its path. Also handles unlocking and removal; installed release users do not need this setup. +--- + +# macOS CUA development signing + +## Recognize developer use and recommend signing + +When a macOS developer uses DSH Computer Use from a source checkout, proactively recommend a reusable personal signing identity. Relevant signals include launching DSH from source, rebuilding the helper, preparing another worktree, or investigating repeated permission prompts. The developer does not need to be editing CUA code, and does not need to ask about certificates first. Before a cross-worktree permission-retention test, establish that both builds select the same fixed identity. + +Use the task context and available configuration to choose the recommendation. If an identity is already configured, recommend reusing it and unlock it when needed; do not propose another key for each worktree. If signing is unset or `ad-hoc`, briefly explain that code-hash changes can require fresh grants, recommend creating one local identity under `~/.cua-macos-keys/`, and offer to configure `cordis.patch.yml` to select it. Mention that the first macOS grant remains manual. Follow the authorization rule below before creating keys or editing configuration; a recommendation alone does not authorize setup. + +For example: “Since you are using Computer Use from source and switching worktrees, I recommend creating one personal signing identity and reusing it across builds to reduce repeated authorization. I can generate it under `~/.cua-macos-keys/` and configure this DSH home to use it. You will still grant macOS permissions once.” Adapt the wording to the user's language and task. + +Do not recommend developer keys merely because a user is using the installed DSH app, working on an unrelated repository, or developing without Computer Use. If they choose to keep ad-hoc signing, continue their task and do not repeat the recommendation without a relevant change in circumstances. + +## Understand the signing choices + +Source builds default to `sourceSigning: ad-hoc`. They need no developer certificate. An ad-hoc designated requirement includes the code hash, so changed binaries, clean rebuilds, or different worktrees can require fresh Accessibility and Screen Recording grants even when their sources are unchanged. For developers who want fewer repeated grants, configure one fixed certificate and use it across builds. The first grant is still manual; verify actual permission retention rather than claiming that matching signatures alone prove it. + +The [macOS provider](../../../packages/experimental/cua-macos/README.md#use-this-package) owns the two configuration choices: `ad-hoc`, or `{ identity, keychain }`. The build consumes an existing certificate; it never creates or unlocks one. Developer key material stays outside DSH home. DSH home stores only the configuration selecting it. + +## Create or reuse an identity + +Run from a repository checkout on macOS with the selected Apple command-line tools. An explicit request to set up developer signing or reduce repeated development grants authorizes setup and the corresponding local configuration edit. For advice-only requests, explain the choices without creating an identity. + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup +``` + +The script creates or reuses `~/.cua-macos-keys/dev/signing.keychain-db`, stores its password and certificate fingerprint in the adjacent `identity.json` with mode `0600`, and keeps the directory private. It adds only its dedicated keychain to the user search list and enables `codesign` access without prompting for the login password. A sibling `dev.lock` serializes setup and removal across worktrees; custom directory names keep their full name before `.lock`. Supply `--directory ` when the developer chose another location outside DSH home and the checkout. + +Setup claims only a new or empty directory and writes `.dsh-cua-macos-signing` as its ownership marker. Setup and removal refuse directories without that marker, unexpected entries, or symlinked identity files before changing keychains or identity files. Preserve unmarked identities from earlier tooling; do not add a marker automatically. They can still be selected as existing user keychains. + +Setup prints only the certificate fingerprint and absolute keychain path as JSON. Keep the password record out of chat, logs, commits, and configuration. Other processes running as this user can read the stored password and use this development identity; this is the local developer setup's access model. + +If an existing record is damaged or its keychain/certificate is missing, setup stops. Do not silently replace that identity: replacement changes the certificate and can require new grants. Follow the reported remove/setup commands only when identity replacement is authorized. + +## Configure the source build + +Resolve the DSH home used by the developer (`DSH_HOME`, otherwise `~/.dsh`). Merge the two printed values into its `cordis.patch.yml`, targeting the entry that loads `@deepseek-ai/dsh-experimental-cua-macos`. The shipped entry ID is `cua`: + +```yaml +- id: cua + config: + sourceSigning: + identity: "" + keychain: "/Users//.cua-macos-keys/dev/signing.keychain-db" +``` + +Use the printed absolute path, not a literal `~`. Preserve unrelated patches and every existing field in this entry's effective config: a Cordis patch replaces the complete `config` object rather than deep-merging it. If the provider has `helperPath`, source signing is bypassed; remove that override only when the developer intends to test the source-built helper. Ensure the profile actually loads the provider before adding a patch for its ID. + +Launch through the developer's existing `dsh` profile. The home-level patch shares the selection across profiles and worktrees that use that home. Other DSH homes can explicitly select the same certificate. Record the paths changed and the selected public fingerprint, never the password. + +## Unlock and verify + +After a restart or a manual keychain lock, rerun `setup`; it unlocks and reuses the existing identity. Do not ask the developer for the login-keychain password or store one. A source build does not automatically unlock the keychain and can fail or prompt if it is locked. + +Grant Accessibility and Screen Recording to `DSH Computer Use Dev.app` in System Settings. Call `getCapabilities`, change and rebuild the Swift helper, then test another worktree with the same configuration. Report the observed permission flags for each step. Matching `codesign -d -r- ` requirements confirm signature continuity but do not replace the permission checks. + +For a temporary, permission-free signing mechanism check, run: + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py +``` + +It creates an isolated identity, tests reuse and unlocking, signs changed binaries, then removes its keychain and checks search-list restoration. Run it without concurrent keychain-list mutations. + +## Remove an identity + +First remove the selecting configuration or set `sourceSigning: ad-hoc` in each affected home. For an authorized removal, run: + +```sh +python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove +``` + +Use the same `--directory` for a custom location. Removal deletes only the marked identity’s files and its search-list entry, then removes the empty directory; the empty sibling lock remains so waiting processes still coordinate. Deleting DSH home alone leaves the developer identity intact. System Settings permission entries are separate; remove them only when requested. diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py b/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py new file mode 100644 index 0000000000..71c36752d5 --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/signing_e2e.py @@ -0,0 +1,77 @@ +"""Real development signing keeps one designated requirement across changed builds. + +Creates a temporary development identity, adds its keychain to the user search list for the duration +of the check, and removes both afterwards. No system permission or password dialog is requested. +""" +import importlib.util +import json +from pathlib import Path +import plistlib +import subprocess +import sys +import tempfile + +NATIVE = Path(__file__).resolve().parents[4] / "packages/experimental/cua-macos/native" + + +def module(name): + spec = importlib.util.spec_from_file_location(name, NATIVE / "build_app.py" if name == "build_app" else Path(__file__).with_name("signing_identity.py")) + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +def requirement(application): + output = subprocess.run(["/usr/bin/codesign", "-d", "-r-", str(application)], check=True, capture_output=True, text=True) + return next(line.split("=> ", 1)[1] for line in (output.stdout + output.stderr).splitlines() if "designated =>" in line) + + +def main(): + builder, signing = module("build_app"), module("dev_signing") + before = signing.user_keychains() + with tempfile.TemporaryDirectory(prefix="cua-dev-signing-e2e-") as temporary: + root = Path(temporary) + directory = root / "keys/dev" + try: + command = [sys.executable, str(Path(__file__).with_name("signing_identity.py")), "setup", "--directory", str(directory)] + configuration = json.loads(subprocess.run(command, check=True, capture_output=True, text=True).stdout) + identity = signing.setup(directory) + assert configuration == {"identity": identity.sha1, "keychain": str(identity.keychain.resolve())} + assert signing.setup(directory) == identity + sha1, keychain = identity.sha1, identity.keychain + assert (directory / "identity.json").stat().st_mode & 0o777 == 0o600 + signing.security("lock-keychain", str(keychain)) + assert signing.setup(directory) == identity, "setup must unlock an existing developer keychain" + requirements = [] + for status in (0, 1): + (root / "main.c").write_text(f"int main(void) {{ return {status}; }}\n") + subprocess.run(["/usr/bin/xcrun", "clang", "-o", str(root / "helper"), str(root / "main.c")], check=True) + executable = builder.publish(root / "helper", root / f"checkout-{status}", sha1, NATIVE / "Info.plist", "dev", keychain) + application = executable.parents[2] + assert application.name == "DSH Computer Use Dev.app", application + subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(application)], check=True) + info = plistlib.loads((application / "Contents/Info.plist").read_bytes()) + assert info["CFBundleIdentifier"] == "com.deepseek.harness.computer-use.dev", info + requirements.append((application, requirement(application))) + (first, first_requirement), (second, second_requirement) = requirements + assert first != second, "changed builds must publish separate generations" + assert first_requirement == second_requirement, requirements + assert "certificate leaf" in first_requirement and "cdhash" not in first_requirement, first_requirement + subprocess.run(["/usr/bin/codesign", "--verify", f"-R={first_requirement}", str(second)], check=True) + selected = json.loads((root / "checkout-1/current.json").read_text())["bundle"] + assert second.parent.name == selected + for index, (certificate, selected_keychain) in enumerate(((sha1, keychain), (signing.COMMON_NAME, keychain))): + custom = builder.publish(root / "helper", root / f"custom-{index}", certificate, NATIVE / "Info.plist", "dev", selected_keychain) + assert requirement(custom.parents[2]) == first_requirement + print(f"PASS: developer setup reuses and unlocks its identity; configured signatures across changed builds share {first_requirement}") + finally: + try: + signing.remove(directory) + finally: + # This fixture owns the temporary directory even if ownership validation fails. + signing.clear_identity(directory) + assert signing.user_keychains() == before, "the user keychain search list was not restored" + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py b/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py new file mode 100644 index 0000000000..69ab639a9d --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py @@ -0,0 +1,258 @@ +"""Manage an explicitly requested developer identity outside DSH home. + +macOS keeps Accessibility and Screen Recording grants for a bundle identifier together with the +designated requirement of the signature that was granted. An ad-hoc signature's requirement is the +code hash, so every changed build loses the grant. A certificate signature's requirement names the +bundle identifier and the certificate, preserving the requirement across changed builds. + +The identity lives in a dedicated keychain whose password is stored beside it with owner-only +permissions, so developer setup can unlock it without a system dialog. Any process running as the same +user can read that password and sign code that inherits the development grants; the grants belong +to the development bundle identifier only, never to the release helper. +""" +import argparse +from contextlib import contextmanager +from dataclasses import dataclass +import fcntl +import json +import os +from pathlib import Path +import re +import secrets +import shlex +import subprocess +import sys +import tempfile + +COMMON_NAME = "DSH Computer Use Dev (local)" +OWNER_MARKER = ".dsh-cua-macos-signing" +OWNER_VERSION = "1\n" +SECURITY = "/usr/bin/security" +OPENSSL = "/usr/bin/openssl" +SETUP_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py setup" +REMOVE_COMMAND = "python3 .agents/skills/dsh-cua-macos-signing/scripts/signing_identity.py remove" +CERTIFICATE_CONFIG = f"""[req] +distinguished_name = subject +x509_extensions = extensions +prompt = no +[subject] +CN = {COMMON_NAME} +[extensions] +basicConstraints = critical,CA:false +keyUsage = critical,digitalSignature +extendedKeyUsage = critical,codeSigning +""" + + +@dataclass(frozen=True) +class SigningIdentity: + """An unlocked certificate identity that codesign can use without user interaction.""" + sha1: str + keychain: Path + + +def private_command(command, **kwargs): + """Run signing tools without exposing secret arguments or their echoed output in diagnostics.""" + operation = f"{Path(command[0]).name} {command[1]}" + try: + return subprocess.run(command, check=True, capture_output=True, text=True, timeout=60, **kwargs).stdout + except subprocess.CalledProcessError as error: + raise RuntimeError(f"{operation} failed (exit {error.returncode})") from None + except subprocess.TimeoutExpired: + raise RuntimeError(f"{operation} timed out after 60 seconds") from None + except OSError as error: + raise RuntimeError(f"{operation} could not run (errno {error.errno})") from None + + +def security(*args): + return private_command([SECURITY, *args]) + + +@contextmanager +def identity_lock(directory): + """Serialize setup and removal across worktrees; the lock inode survives identity removal.""" + directory.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + with os.fdopen(os.open(directory.parent / f"{directory.name}.lock", os.O_WRONLY | os.O_CREAT, 0o600), "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + yield + + +def user_keychains(): + """Read the user keychain search list that codesign consults for identities.""" + return [line.strip().strip('"') for line in security("list-keychains", "-d", "user").splitlines() if line.strip()] + + +def listed(keychain, keychains): + """Compare canonical paths; security reports /private/var for /var and resolves other links.""" + return any(os.path.realpath(path) == os.path.realpath(keychain) for path in keychains) + + +def record_path(directory): + return directory / "identity.json" + + +def keychain_path(directory): + return directory / "signing.keychain-db" + + +def default_directory(): + """Keep developer credentials independent of DSH profiles, homes, and worktrees.""" + return Path.home() / ".cua-macos-keys" / "dev" + + +def owned_directory(directory, create=False): + """Refuse unowned or mixed-content directories before changing files or keychains.""" + marker = directory / OWNER_MARKER + if create: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + if not any(directory.iterdir()): + directory.chmod(0o700) + with os.fdopen(os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file: + file.write(OWNER_VERSION) + if not directory.exists(): + return + if marker.is_symlink() or not marker.is_file() or marker.read_bytes() != OWNER_VERSION.encode(): + raise RuntimeError(f"Refusing unowned signing directory: {directory}; choose a new or empty dedicated directory") + allowed = {OWNER_MARKER, record_path(directory).name, record_path(directory).with_suffix(".tmp").name, keychain_path(directory).name} + for entry in directory.iterdir(): + # security creates empty .flXXXXXXXX locks and removes them with delete-keychain. + system_lock = re.fullmatch(r"\.fl[0-9A-Fa-f]{8}", entry.name) and entry.is_file() and entry.stat().st_size == 0 + if (entry.name not in allowed and not system_lock) or entry.is_symlink() or not entry.is_file(): + raise RuntimeError(f"Refusing signing directory with unrelated entries: {directory}; preserve those entries before retrying") + + +def load(directory): + """Unlock the configured identity before signing. + + Returns None when setup has not run. Raises when setup ran but its keychain is missing or no + longer on the search list, because signing would otherwise fail or prompt for a password. + """ + directory_argument = shlex.quote(str(directory)) + setup_command = f"{SETUP_COMMAND} --directory {directory_argument}" + remove_command = f"{REMOVE_COMMAND} --directory {directory_argument}" + record = record_path(directory) + if not record.exists(): + return None + try: + fields = json.loads(record.read_text()) + valid = isinstance(fields, dict) and isinstance(fields.get("password"), str) and isinstance(fields.get("sha1"), str) and bool(fields["password"]) and bool(fields["sha1"]) + except (ValueError, OSError): + valid = False + if not valid: + raise RuntimeError(f"Computer Use development signing record is invalid; run {remove_command}, then {setup_command}") + keychain = keychain_path(directory) + if not keychain.is_file(): + raise RuntimeError(f"Computer Use development signing keychain is missing: {keychain}; run {remove_command}, then {setup_command}") + if not listed(keychain, user_keychains()): + raise RuntimeError(f"Computer Use development signing keychain is not on the user keychain search list; run {setup_command}") + # A locked keychain makes codesign show a password dialog, so unlock it explicitly every time. + security("unlock-keychain", "-p", fields["password"], str(keychain)) + if fields["sha1"] not in security("find-identity", str(keychain)): + raise RuntimeError(f"Computer Use development signing certificate is missing; run {remove_command}, then {setup_command}") + return SigningIdentity(sha1=fields["sha1"], keychain=keychain) + + +def create_certificate(directory, password): + """Create the key pair inside the dedicated keychain and return the certificate SHA-1.""" + keychain = keychain_path(directory) + with tempfile.TemporaryDirectory(prefix=".certificate-", dir=directory) as temporary: + work = Path(temporary) + (work / "openssl.cnf").write_text(CERTIFICATE_CONFIG) + export_password = secrets.token_hex(16) + for command in ( + ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "key.pem", "-out", "certificate.pem", + "-days", "3650", "-config", "openssl.cnf"], + ["pkcs12", "-export", "-inkey", "key.pem", "-in", "certificate.pem", "-out", "identity.p12", + "-passout", f"pass:{export_password}"], + ): + private_command([OPENSSL, *command], cwd=work) + fingerprint = private_command([OPENSSL, "x509", "-in", "certificate.pem", "-noout", "-fingerprint", "-sha1"], cwd=work) + security("create-keychain", "-p", password, str(keychain)) + security("unlock-keychain", "-p", password, str(keychain)) + # No arguments disables the inactivity timeout and lock on sleep. + security("set-keychain-settings", str(keychain)) + security("import", str(work / "identity.p12"), "-k", str(keychain), "-P", export_password, "-T", "/usr/bin/codesign") + # Without a partition list, codesign asks the user to allow access to the private key. + security("set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, str(keychain)) + match = re.search(r"=([0-9A-F:]+)", fingerprint) + if match is None: + raise RuntimeError("openssl returned no certificate fingerprint") + sha1 = match.group(1).replace(":", "") + if sha1 not in security("find-identity", str(keychain)): + raise RuntimeError("the imported development signing identity is not usable") + return sha1 + + +def setup(directory): + """Create an identity only without a record; preserve recorded identities when their keychain is missing.""" + with identity_lock(directory): + return setup_locked(directory) + + +def setup_locked(directory): + owned_directory(directory, create=True) + keychain = keychain_path(directory) + if record_path(directory).exists(): + if keychain.is_file(): + keychains = user_keychains() + if not listed(keychain, keychains): + security("list-keychains", "-d", "user", "-s", *keychains, str(keychain)) + return load(directory) + clear_identity(directory) + password = secrets.token_hex(32) + sha1 = create_certificate(directory, password) + security("list-keychains", "-d", "user", "-s", *user_keychains(), str(keychain)) + record = record_path(directory) + with os.fdopen(os.open(record.with_suffix(".tmp"), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as file: + json.dump({"sha1": sha1, "password": password}, file) + file.write("\n") + os.replace(record.with_suffix(".tmp"), record) + return load(directory) + + +def remove(directory): + """Delete the keychain, its search-list entry and the stored password.""" + with identity_lock(directory): + remove_locked(directory) + + +def remove_locked(directory): + owned_directory(directory) + clear_identity(directory) + if directory.exists(): + (directory / OWNER_MARKER).unlink() + directory.rmdir() + + +def clear_identity(directory): + """Remove only the identity files and its exact keychain search-list entry.""" + keychain = keychain_path(directory) + keychains = user_keychains() + if listed(keychain, keychains): + security("list-keychains", "-d", "user", "-s", *[path for path in keychains if not listed(keychain, [path])]) + if keychain.exists(): + security("delete-keychain", str(keychain)) + record_path(directory).unlink(missing_ok=True) + record_path(directory).with_suffix(".tmp").unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description="Manage the local signing identity for source-built Computer Use applications.") + parser.add_argument("action", choices=["setup", "remove"]) + parser.add_argument("--directory", type=Path, default=default_directory(), help="identity directory; defaults to ~/.cua-macos-keys/dev") + args = parser.parse_args() + directory = args.directory.expanduser().resolve() + if args.action == "setup": + identity = setup(directory) + print(json.dumps({"identity": identity.sha1, "keychain": str(identity.keychain)}, indent=2)) + else: + remove(directory) + print("Removed the development signing identity; remove its sourceSigning configuration before the next build") + + +if __name__ == "__main__": + try: + main() + except RuntimeError as error: + print(error, file=sys.stderr) + sys.exit(1) diff --git a/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py b/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py new file mode 100644 index 0000000000..949926f98c --- /dev/null +++ b/.agents/skills/dsh-cua-macos-signing/scripts/test_signing_identity.py @@ -0,0 +1,335 @@ +"""Development signing setup keeps one unlocked identity on the user keychain search list.""" +import contextlib +import io +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("dev_signing", Path(__file__).with_name("signing_identity.py")) +signing = importlib.util.module_from_spec(spec) +spec.loader.exec_module(signing) + + +class FakeSecurity: + """Record security commands and model the user search list.""" + def __init__(self, keychains): + self.keychains = list(keychains) + self.commands = [] + + def __call__(self, *args): + self.commands.append(args) + if args[:3] == ("list-keychains", "-d", "user") and len(args) == 3: + return "".join(f' "{path}"\n' for path in self.keychains) + if args[:4] == ("list-keychains", "-d", "user", "-s"): + self.keychains = list(args[4:]) + if args[0] == "delete-keychain": + Path(args[1]).unlink() + if args[0] == "find-identity": + return "ABC123 NEW456" + return "" + + +class DevelopmentSigningTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="cua-dev-signing-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name).resolve() + self.directory = self.root / "signing" + self.keychain = self.directory / "signing.keychain-db" + self.login = "/Users/example/Library/Keychains/login.keychain-db" + + def configure(self, listed=True): + self.directory.mkdir(mode=0o700) + (self.directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION) + self.keychain.write_bytes(b"keychain") + (self.directory / "identity.json").write_text(json.dumps({"sha1": "ABC123", "password": "secret"})) + return self.fake([self.login, str(self.keychain)] if listed else [self.login]) + + def fake(self, keychains): + security = FakeSecurity(keychains) + patcher = patch.object(signing, "security", security) + patcher.start() + self.addCleanup(patcher.stop) + return security + + def test_setup_and_remove_preserve_unowned_directory_contents(self): + security = self.fake([self.login]) + self.directory.mkdir() + unrelated = self.directory / "notes.txt" + unrelated.write_text("keep this document") + for operation in (signing.setup, signing.remove): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(RuntimeError, "Refusing unowned signing directory"): + operation(self.directory) + self.assertEqual(unrelated.read_text(), "keep this document") + self.assertEqual(list(self.directory.iterdir()), [unrelated]) + self.assertEqual(security.commands, []) + + def test_unrelated_files_in_an_owned_directory_block_setup_and_removal(self): + security = self.configure() + unrelated = self.directory / "notes.txt" + unrelated.write_text("keep this document") + before = {p.name: p.read_bytes() for p in self.directory.iterdir()} + for operation in (signing.setup, signing.remove): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual({p.name: p.read_bytes() for p in self.directory.iterdir()}, before) + self.assertEqual(security.commands, []) + + def test_system_keychain_locks_are_removed_by_security(self): + security = self.configure() + lock = self.directory / ".flD3051040" + lock.touch() + def remove_keychain(*args): + result = security(*args) + if args[0] == "delete-keychain": + lock.unlink() + return result + with patch.object(signing, "security", side_effect=remove_keychain): + signing.remove(self.directory) + self.assertFalse(self.directory.exists()) + + def test_nonempty_files_with_a_system_lock_name_are_preserved(self): + security = self.configure() + lock = self.directory / ".flD3051040" + lock.write_text("unrelated contents") + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual(lock.read_text(), "unrelated contents") + self.assertEqual(security.commands, []) + + def test_missing_or_invalid_marker_cannot_adopt_an_existing_identity(self): + security = self.configure() + marker = self.directory / signing.OWNER_MARKER + for contents in (None, "different tool", "\ufffd"): + if contents is None: + marker.unlink() + else: + marker.write_text(contents) + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unowned signing directory"): + operation(self.directory) + self.assertTrue(self.keychain.is_file()) + self.assertEqual(security.commands, []) + + def test_symlinked_identity_files_do_not_delete_another_directorys_data(self): + security = self.configure() + outside = self.root / "outside.keychain-db" + outside.write_bytes(b"other identity") + self.keychain.unlink() + self.keychain.symlink_to(outside) + for operation in (signing.setup, signing.remove): + with self.assertRaisesRegex(RuntimeError, "unrelated entries"): + operation(self.directory) + self.assertEqual(outside.read_bytes(), b"other identity") + self.assertEqual(security.commands, []) + + def test_empty_directory_gets_an_ownership_marker_before_generation(self): + self.fake([self.login]) + self.directory.mkdir() + with patch.object(signing, "create_certificate", side_effect=RuntimeError("generation interrupted")): + with self.assertRaisesRegex(RuntimeError, "generation interrupted"): + signing.setup(self.directory) + self.assertEqual((self.directory / signing.OWNER_MARKER).read_text(), signing.OWNER_VERSION) + signing.remove(self.directory) + self.assertFalse(self.directory.exists()) + + def test_removing_the_only_search_entry_leaves_no_unrelated_entries_to_restore(self): + security = self.configure() + security.keychains = [str(self.keychain)] + signing.remove(self.directory) + self.assertEqual(security.keychains, []) + self.assertFalse(self.directory.exists()) + + def test_dotted_identity_directories_keep_separate_lock_files(self): + directories = [self.root / name for name in ("dev", "dev.keys", "keys.v1", "keys.v2")] + for directory in directories: + with signing.identity_lock(directory): + pass + self.assertEqual({p.name for p in self.root.iterdir()}, {f"{p.name}.lock" for p in directories}) + + def test_default_identity_is_outside_dsh_home(self): + home = self.root / "user" + with patch.object(Path, "home", return_value=home): + for value in ("", str(self.root / "custom-dsh-home")): + with patch.dict(signing.os.environ, {"DSH_HOME": value}): + self.assertEqual(signing.default_directory(), home / ".cua-macos-keys/dev") + self.assertFalse(home.exists()) + + def test_setup_and_remove_target_the_explicit_identity_directory(self): + directory = self.root / "keys/dev" + identity = signing.SigningIdentity("ABC123", directory / "signing.keychain-db") + for action in ("setup", "remove"): + with patch.object(signing.sys, "argv", ["signing_identity.py", action, "--directory", str(directory)]), \ + patch.object(signing, action, return_value=identity) as operation, contextlib.redirect_stdout(io.StringIO()) as output: + signing.main() + operation.assert_called_once_with(directory) + if action == "setup": + self.assertEqual(json.loads(output.getvalue()), {"identity": "ABC123", "keychain": str(identity.keychain)}) + + def test_missing_setup_leaves_builds_unconfigured(self): + self.fake([self.login]) + self.assertIsNone(signing.load(self.directory)) + + def test_load_unlocks_the_keychain_before_returning_the_identity(self): + security = self.configure() + identity = signing.load(self.directory) + self.assertEqual((identity.sha1, identity.keychain), ("ABC123", self.keychain)) + self.assertIn(("unlock-keychain", "-p", "secret", str(self.keychain)), security.commands) + + def test_listed_keychains_match_through_symbolic_links(self): + (self.root / "alias").symlink_to(self.root, target_is_directory=True) + self.configure() + signing.security.keychains = [self.login, str(self.root / "alias/signing/signing.keychain-db")] + self.assertEqual(signing.load(self.directory).sha1, "ABC123") + + def test_unlisted_or_missing_keychains_fail_with_the_setup_command(self): + security = self.configure(listed=False) + with self.assertRaisesRegex(RuntimeError, "search list; run python3"): + signing.load(self.directory) + self.keychain.unlink() + with self.assertRaisesRegex(RuntimeError, "keychain is missing"): + signing.load(self.directory) + self.assertFalse(any(command[0] == "unlock-keychain" for command in security.commands)) + + def test_setup_relists_an_existing_identity_without_creating_another(self): + security = self.configure(listed=False) + with patch.object(signing, "create_certificate") as create: + self.assertEqual(signing.setup(self.directory).sha1, "ABC123") + create.assert_not_called() + self.assertEqual(security.keychains, [self.login, str(self.keychain)]) + + def test_setup_replaces_only_an_incomplete_owned_identity(self): + security = self.configure() + (self.directory / "identity.json").unlink() + def create(directory, password): + self.keychain.write_bytes(b"new keychain") + return "NEW456" + with patch.object(signing, "create_certificate", side_effect=create): + identity = signing.setup(self.directory) + self.assertEqual(identity.sha1, "NEW456") + self.assertEqual(self.keychain.read_bytes(), b"new keychain") + self.assertEqual(oct((self.directory / "identity.json").stat().st_mode & 0o777), "0o600") + self.assertEqual(oct(self.directory.stat().st_mode & 0o777), "0o700") + self.assertEqual(security.keychains, [self.login, str(self.keychain)]) + + def test_setup_preserves_the_record_when_the_keychain_is_missing(self): + security = self.configure() + record = self.directory / "identity.json" + before = record.read_bytes() + self.keychain.unlink() + with patch.object(signing, "create_certificate") as create: + with self.assertRaisesRegex(RuntimeError, "keychain is missing: .*; run .*remove --directory .*, then .*setup --directory "): + signing.setup(self.directory) + create.assert_not_called() + self.assertEqual(record.read_bytes(), before) + self.assertFalse(self.keychain.exists()) + self.assertEqual(security.commands, []) + + def test_setup_cli_preserves_a_recorded_identity_missing_its_keychain(self): + directory = self.root / "keys/dev" + directory.mkdir(parents=True) + (directory / signing.OWNER_MARKER).write_text(signing.OWNER_VERSION) + record = directory / "identity.json" + before = json.dumps({"sha1": "ABC123", "password": "secret-password"}).encode() + record.write_bytes(before) + result = subprocess.run([sys.executable, str(Path(signing.__file__)), "setup", "--directory", str(directory)], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertIn("keychain is missing", result.stderr) + self.assertIn(f"run {signing.REMOVE_COMMAND} --directory {directory}, then {signing.SETUP_COMMAND} --directory {directory}", result.stderr) + self.assertNotIn("Traceback", result.stderr) + self.assertNotIn("secret-password", result.stderr) + self.assertEqual(record.read_bytes(), before) + self.assertFalse(signing.keychain_path(directory).exists()) + + def test_remove_restores_the_other_search_list_entries_and_deletes_the_password(self): + security = self.configure() + signing.remove(self.directory) + self.assertEqual(security.keychains, [self.login]) + self.assertFalse(self.directory.exists()) + signing.remove(self.directory) + self.assertEqual(security.keychains, [self.login]) + + def test_missing_certificate_requires_explicit_removal_instead_of_changing_identity(self): + self.configure() + (self.directory / "identity.json").write_text(json.dumps({"sha1": "MISSING", "password": "secret"})) + with self.assertRaisesRegex(RuntimeError, "certificate is missing; run .*remove --directory .*, then .*setup --directory "): + signing.setup(self.directory) + self.assertTrue(self.keychain.is_file()) + + def test_invalid_identity_record_reports_recovery_without_its_contents(self): + self.configure() + (self.directory / "identity.json").write_text("secret invalid json") + with self.assertRaisesRegex(RuntimeError, "record is invalid; run .*remove") as error: + signing.setup(self.directory) + self.assertNotIn("secret", str(error.exception)) + + def test_private_command_failures_never_echo_arguments_or_output(self): + for tool, action in ((signing.SECURITY, "unlock-keychain"), (signing.OPENSSL, "pkcs12")): + command = [tool, action, "-p", "secret-password"] + for failure in (subprocess.TimeoutExpired(command, 60, stderr="secret-password"), + subprocess.CalledProcessError(1, command, stderr="secret-password"), + OSError(2, "secret-password")): + with self.subTest(tool=tool, failure=type(failure).__name__), \ + patch.object(signing.subprocess, "run", side_effect=failure): + with self.assertRaises(RuntimeError) as error: + signing.private_command(command) + self.assertNotIn("secret-password", str(error.exception)) + self.assertTrue(error.exception.__suppress_context__) + + def test_setup_and_remove_hold_the_same_cross_process_lock(self): + self.configure() + probe = """import fcntl, os, sys +with open(sys.argv[1], 'w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(0) + sys.exit(1) +""" + setup_locked, remove_locked = signing.setup_locked, signing.remove_locked + def check_lock(operation): + def run(directory): + result = subprocess.run([sys.executable, "-c", probe, str(directory.parent / f"{directory.name}.lock")], + capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + return operation(directory) + return run + with patch.object(signing, "setup_locked", side_effect=check_lock(setup_locked)), \ + patch.object(signing, "remove_locked", side_effect=check_lock(remove_locked)): + signing.setup(self.directory) + signing.remove(self.directory) + with signing.identity_lock(self.directory): + pass + + def test_setup_cli_reports_signing_timeout_without_traceback_or_password(self): + script = """import runpy, sys, subprocess +from unittest.mock import patch +from pathlib import Path +test_home = Path(sys.argv[2]) +Path.home = lambda: test_home +sys.argv = [sys.argv[1], 'setup', '--directory', sys.argv[2]] +with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['security', 'unlock-keychain', '-p', 'secret-password'], 60)): + runpy.run_path(sys.argv[0], run_name='__main__') +""" + # Fail at the first security command, before setup can modify any keychain. + result = subprocess.run([sys.executable, "-c", script, str(Path(signing.__file__)), str(self.root / "home")], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 1) + self.assertEqual(len(result.stderr.splitlines()), 1) + self.assertIn("timed out", result.stderr) + self.assertNotIn("secret-password", result.stderr) + self.assertNotIn("Traceback", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 57830f1af0..27823aed50 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. +The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index cc835b8933..4f60c1b2a7 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -127,6 +127,23 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build and stage notarized Computer Use helpers + # A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime, + # which therefore needs the same notarized helper npm publication stages. + run: | + if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then + echo 'This source does not ship Computer Use in Desktop; no helper is staged.' + exit 0 + fi + if [[ ! -f scripts/cua-helper/package.ts ]]; then + echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.' + exit 0 + fi + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 073f1e9541..eff4ebe10d 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -124,6 +124,23 @@ jobs: args=() if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build and stage notarized Computer Use helpers + # A source whose dsh ships the Computer Use bundle packs the workspace provider into the Desktop runtime, + # which therefore needs the same notarized helper npm publication stages. + run: | + if ! node -e "process.exit(require('./apps/cli/package.json').dependencies?.['@deepseek-ai/dsh-cua-bundle'] ? 0 : 1)"; then + echo 'This source does not ship Computer Use in Desktop; no helper is staged.' + exit 0 + fi + if [[ ! -f scripts/cua-helper/package.ts ]]; then + echo '::warning::This source ships Computer Use but predates helper packaging; Computer Use cannot prepare its helper in this package.' + exit 0 + fi + mkdir -p dist + pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper + mkdir dist/cua-helper/extracted + tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted + pnpm exec tsx scripts/cua-helper/stage.ts --from dist/cua-helper/extracted - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} diff --git a/.github/workflows/pr-check-after-approve.yml b/.github/workflows/pr-check-after-approve.yml index 092b5b957d..85e84e5c89 100644 --- a/.github/workflows/pr-check-after-approve.yml +++ b/.github/workflows/pr-check-after-approve.yml @@ -1,7 +1,7 @@ name: Policy on: pull_request_target: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] types: [opened, synchronize, reopened, ready_for_review] pull_request_review: types: [submitted, dismissed] diff --git a/.github/workflows/pr-check-on-base-branch.yml b/.github/workflows/pr-check-on-base-branch.yml index 32d4d46165..b4a9a474e6 100644 --- a/.github/workflows/pr-check-on-base-branch.yml +++ b/.github/workflows/pr-check-on-base-branch.yml @@ -1,7 +1,7 @@ name: Policy on: push: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] workflow_dispatch: permissions: {} concurrency: diff --git a/.github/workflows/pr-check-on-head-branch.yml b/.github/workflows/pr-check-on-head-branch.yml index adb884d62c..a926d97827 100644 --- a/.github/workflows/pr-check-on-head-branch.yml +++ b/.github/workflows/pr-check-on-head-branch.yml @@ -1,7 +1,7 @@ name: Policy on: pull_request_target: - branches: [master] + branches: [master, 'release/dsh-[0-9]+.[0-9]+.[0-9]+'] types: [opened, synchronize, reopened, ready_for_review, review_requested, auto_merge_enabled, labeled] permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 69dbd7d69e..db164319da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// compaction/ context compaction context/ request context subagent/ delegated agents + team/ shared task coordination jobs/ background jobs bundle/ profile bundles workflow/ workflow execution diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 21150774cc..96b711a333 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -140,6 +140,10 @@ flowchart LR cfg --> plugin_dsh_base_command_compact plugin_dsh_base_subagent["subagent
@deepseek-ai/dsh-subagent"] cfg --> plugin_dsh_base_subagent + plugin_dsh_base_agent_team["agent-team
@deepseek-ai/dsh-agent-team"] + cfg --> plugin_dsh_base_agent_team + plugin_dsh_base_tool_agent_team["tool-agent-team
@deepseek-ai/dsh-tool-agent-team"] + cfg --> plugin_dsh_base_tool_agent_team plugin_dsh_base_subagent_spawn_in_process["subagent-spawn-in-process
@deepseek-ai/dsh-subagent-spawn-in-process"] cfg --> plugin_dsh_base_subagent_spawn_in_process plugin_dsh_base_subagent_fork_in_process["subagent-fork-in-process
@deepseek-ai/dsh-subagent-fork-in-process"] @@ -272,6 +276,8 @@ flowchart LR | `compaction-basic` | `@deepseek-ai/dsh-compaction-basic` | | `command-compact` | `@deepseek-ai/dsh-command-compact` | | `subagent` | `@deepseek-ai/dsh-subagent` | +| `agent-team` | `@deepseek-ai/dsh-agent-team` | +| `tool-agent-team` | `@deepseek-ai/dsh-tool-agent-team` | | `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` | | `subagent-fork-in-process` | `@deepseek-ai/dsh-subagent-fork-in-process` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | diff --git a/apps/cli/package.json b/apps/cli/package.json index 463f4f1b9e..3df78de1fb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", "@deepseek-ai/dsh-experimental-inspector-profile": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", @@ -107,6 +106,7 @@ "@deepseek-ai/dsh-experimental-liveshare": "workspace:*", "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", + "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", "@deepseek-ai/dsh-personalized-recommendations": "workspace:*", "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" @@ -127,9 +127,9 @@ "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:*", - "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:*", + "@deepseek-ai/dsh-tool-agent-team": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:*", "@deepseek-ai/dsh-host-frontend-static": "workspace:*", diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts index dd4e456252..e38a33777d 100644 --- a/apps/cli/tests/agent-team-headless.e2e.ts +++ b/apps/cli/tests/agent-team-headless.e2e.ts @@ -17,7 +17,7 @@ function records(content: string): Record[] { } describe('dsh run with Agent Teams enabled', () => { - it('runs two teammates, direct peer messages, dependent tasks, waiting, and final aggregation', async () => { + it('runs two teammates, sibling messages, dependent tasks, waiting, and final aggregation', async () => { const cwd = await mkdtemp(join(tmpdir(), 'dsh-agent-team-headless-')) try { const home = join(cwd, '.dsh') @@ -27,15 +27,12 @@ describe('dsh run with Agent Teams enabled', () => { await writeFile(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-headless', private: true, - dependencies: { - '@deepseek-ai/dsh-experimental-agent-team-profile': 'workspace:^', - }, + dependencies: {}, dsh: { profile: { bundles: [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', - '@deepseek-ai/dsh-experimental-agent-team-profile', ], }, }, @@ -91,8 +88,8 @@ describe('dsh run with Agent Teams enabled', () => { const workflowChild = parsed.find(log => log.some(record => record.type === 'user/message' && JSON.stringify((record.data as { content: unknown }).content) === JSON.stringify([{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }]))) expect(workflowChild).toBeDefined() - expect(workflowChild!.find(record => record.type === 'subagent/descriptor')?.data) - .toMatchObject({ mode: 'continuable', provider: 'spawn' }) + const workflowDescriptor = workflowChild!.find(record => record.type === 'subagent/descriptor')?.data as Record + expect(workflowDescriptor).toMatchObject({ mode: 'continuable', provider: 'spawn' }) expect(workflowChild!.filter(record => record.type === 'user/message' && (record.data as { source: { kind: string } }).source.kind === 'user').map(record => record.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) @@ -102,7 +99,15 @@ describe('dsh run with Agent Teams enabled', () => { }) expect(root).toBeDefined() const eventTypes = root!.map(record => record.type) - expect(eventTypes.filter(type => type === 'team/member')).toHaveLength(4) + expect(eventTypes).not.toContain('team/member') + const catalog = root!.filter(record => record.type === 'subagent/catalog').map(record => record.data) + expect(catalog).toEqual(expect.arrayContaining([ + expect.objectContaining({ version: 0, childId: workflowChild![0]?.id }), + expect.objectContaining({ version: 0, label: 'Own deterministic implementation.' }), + expect.objectContaining({ version: 0, label: 'Own deterministic research.' }), + ])) + for (const entry of catalog) expect(entry).not.toHaveProperty('name') + expect(eventTypes).not.toContain('subagent-name/bound') expect(eventTypes).not.toContain('team/message/queued') expect(eventTypes).not.toContain('team/message/delivered') const peerMessages = parsed.flatMap(log => log.filter(record => record.type === 'user/message' diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index b5a5eecbaf..8db33a3a87 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -171,8 +171,8 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('keeps the default host and minimal tool catalogs while every optional bundle is off', async () => { const minimal = await agent('minimal') const standard = await agent() - expect(names()).toEqual(['working_directory']) - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) + expect(names()).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) for (const name of ['str_replace_editor', 'session_search', 'terminal_open', 'ralph']) expect(names(standard.agent)).not.toContain(name) expect((await ctx.skills.list({ cwd: workspace, scope: standard.agent })).some(skill => skill.name === 'dsh-badge')).toBe(false) }) @@ -246,7 +246,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option expect(toolResult(owner.agent, 'badge-on')).toContain('dsh-badge.png') const minimal = await agent('minimal') expect((await ctx.skills.list({ cwd: workspace, scope: minimal.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'working_directory']) + expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) await select(bundles.badge, false) expect((await load('badge-after')).isError).toBe(true) }) diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl index 19495490be..8467970106 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-round-driver/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable two-round goal for the ACP snapshot, inspect it, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable two-round goal","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl index 7e3a02fce9..a4ac54b8c1 100644 --- a/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl +++ b/apps/cli/tests/profiles/acp/tests/goal-expected/goal-wrapup/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Create a durable goal for","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 259ba7341e..63715e4638 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -117,6 +117,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn }, }) expect(result.stderr).toBe('') + if (result.sessionId === undefined) throw new Error('image-offload scenario did not create a session') expect(requests).toHaveLength(2) expect(fileRequests).toEqual([{ method: 'POST', path: filesPath, bytes: 69 }]) const attachmentDigest = 'b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640' @@ -139,10 +140,13 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const runtimeContext = (cwd: string): string => 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + const runtimeContext = (cwd: string, sessionId: string): string => 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + `Current working directory: ${JSON.stringify(cwd)}.\n\n` + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' - + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' + + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\n' + + `You are the root agent. Your agent id is ${JSON.stringify(sessionId)}.\n\n` + + 'To message other agents, use send_message({ agent_id: "", message: "..." }).\n' + + 'Use list_agents({}) to find your collaborator agents and their ids.' const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toEqual([ @@ -152,7 +156,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(result.cwd) }, + { type: 'text', text: runtimeContext(result.cwd, result.sessionId) }, ]) const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> @@ -188,6 +192,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn }, }) expect(fallback.stderr).toBe('') + if (fallback.sessionId === undefined) throw new Error('image-offload fallback scenario did not create a session') expect(fileRequests).toEqual([ { method: 'POST', path: filesPath, bytes: 69 }, { method: 'POST', path: filesPath, bytes: 69 }, @@ -203,7 +208,7 @@ it('pins Messages Files offload and inline fallback in assembled requests', asyn { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - { type: 'text', text: runtimeContext(fallback.cwd) }, + { type: 'text', text: runtimeContext(fallback.cwd, fallback.sessionId) }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs index 5ca8db1ac6..4acea7354d 100644 --- a/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs +++ b/apps/cli/tests/profiles/fixtures/official-installer-observer.mjs @@ -88,7 +88,7 @@ async function exercise(ctx, config) { // A transitive repository package the registry served, absent from npm at this version. const localDependency = JSON.parse(await readFile(join(config.profile, 'node_modules', config.localDependency, 'package.json'), 'utf8')).version const provider = name.endsWith('-codex') ? 'codex' : 'claude-code' - const providerEnabled = ctx.subagents.list().includes(provider) + const providerEnabled = ctx.subagents.getProvider(provider) !== undefined const nativeDir = join(config.profile, 'node_modules', config.nativePackages[name]) const binaryName = `${provider === 'codex' ? 'codex' : 'claude'}${process.platform === 'win32' ? '.exe' : ''}` const nativeBinary = (await readdir(nativeDir, { recursive: true })).find(path => basename(path) === binaryName) diff --git a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml index dec80a9fe9..7a1d4c8a24 100644 --- a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml +++ b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml @@ -21,33 +21,7 @@ root: './.sessions' compression: none -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-report - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - - insert: - - id: agent-team - name: '@deepseek-ai/dsh-experimental-agent-team' - config: - maxMembers: 8 - maxTasks: 256 - maxMessageBytes: 65536 - disposalTimeoutMs: 5000 - - - id: tool-agent-team - name: '@deepseek-ai/dsh-experimental-tool-agent-team' - config: - freshProvider: spawn - forkProvider: fork - - id: team-fixture-llm name: './tests/fixtures/team-llm.mjs' diff --git a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl index c54f84df0b..7779eb2181 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/goal-tools/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl index d45d98dfd0..d4385b311d 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/headless-profile/session.expected.jsonl @@ -8,7 +8,7 @@ {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"cli-mock","model":"cli-mock"}} {"type":"session/title","data":{"title":"Prove the product headless profile","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl index e747f996ad..6cebc92a87 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/invalid-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl index bc7c728199..8cc16706c1 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/missing-credential/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl index 5792ae3774..8488837003 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/provider-retry/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl index 11ae2ec827..c14e345b7f 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/semantic-checkpoint/session.expected.jsonl @@ -18,7 +18,7 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl index 9da1c6379a..57f93111e7 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-diagnostic/parent.expected.jsonl @@ -16,13 +16,13 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [running] — root agent\n{{sessionId}} [inactive] parent={{sessionId}} depth=1 — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"step/start","data":{"turn":2,"step":2}} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl index b0372d96c6..27712bcd00 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Delegated write probe","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash","agentReasoningEffort":"low"}} {"type":"sandbox/mode","data":{"mode":"read-only","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Use the write tool exactly","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl index b9bee477bd..f8796478f5 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-inheritance/parent.expected.jsonl @@ -16,20 +16,20 @@ {"type":"step/start","data":{"turn":2,"step":1}} {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} -{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["apply_patch","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","wait_agent","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}} +{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"apply_patch"},{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"wait_agent"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} {"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Delegated write probe"}} -{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} +{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":1}} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":2,"step":2}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":2,"step":2}} {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl index c07532bd7d..2305a8a7c2 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/child.expected.jsonl @@ -2,13 +2,13 @@ {"type":"subagent/descriptor","data":{"version":3,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"sandbox/mode","data":{"mode":"workspace-write","source":"delegation"}} {"type":"approval/policy","data":{"policy":"never","source":"delegation"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your parent agent id is \"{{sessionId}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"{{sessionId}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids.\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nDelegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are a subagent delegated by agent \"{{sessionId}}\".\nYour agent id is \"{{sessionId}}\".\nThe root agent of your collaboration tree is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"tool:subagent_fork:depth","text":"Delegation depth for `subagent_fork`: current 1, maximum 1, remaining levels 0. Do not call `subagent_fork` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[8],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl index 441cab1aa2..f05ae06cef 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/subagent-settlement/stream-json.expected.jsonl @@ -4,7 +4,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}} @@ -13,15 +13,15 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"start-child"},"toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false,"id":"{{sessionId}}"},"meta":{"kind":"activation","subagentId":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":22,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"STARTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["STARTED"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"STARTED"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":24,"time":0,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":26,"time":0,"data":{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","stopReason":"completed","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PARENT_RECEIVED_SETTLEMENT_NOTICE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_RECEIVED_SETTLEMENT_NOTICE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl index cbb82cb603..56b127865a 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/offline-edit/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl index 3bd9c55f95..dfe1d171a9 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl +++ b/apps/cli/tests/profiles/headless/tests/expected/workspace-context-resume/precedence-change/session.expected.jsonl @@ -17,7 +17,7 @@ {"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}} {"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nYou are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{sessionId}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}} diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs index 9c1bedd00e..62751cd93f 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs +++ b/apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs @@ -15,6 +15,12 @@ function latestAssistantCalls(messages) { return assistant?.content.filter(block => block.type === 'tool-call').map(block => block.name) ?? [] } +function hasNewCoordinationInput(messages) { + const toolIndex = messages.findLastIndex(message => message.role === 'tool') + return messages.some((message, index) => index > toolIndex && message.role === 'user' + && ['agent-message', 'subagent-settled'].includes(message.source?.kind)) +} + function hasTaskAction(messages, action) { return messages.some(message => message.role === 'assistant' && message.content.some((block) => { @@ -63,6 +69,11 @@ function textChunks(text) { function researcher(messages) { const names = calls(messages) + if (!names.includes('list_agents')) return toolChunks([{ name: 'list_agents', args: {} }]) + const sibling = messages.filter(message => message.role === 'tool') + .flatMap(message => message.content.filter(block => block.type === 'text').flatMap(block => block.text.split('\n'))) + .find(line => line.endsWith('— Own deterministic implementation.'))?.split(' ')[0] + if (sibling === undefined) throw new Error('list_agents did not expose the implementation sibling') if (!names.includes('team_task_create')) { return toolChunks([{ name: 'team_task_create', args: { subject: 'Research', description: 'Collect the deterministic finding.', write_scopes: ['research'], @@ -76,13 +87,13 @@ function researcher(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-1', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'implementer', message: 'Research complete: use the deterministic finding.' } }, + { name: 'send_message', args: { agent_id: sibling, message: 'Research complete: use the deterministic finding.' } }, ]) } return textChunks('Research teammate complete.') } -function implementer(messages) { +function implementer(messages, parentId) { const names = calls(messages) const last = latestAssistantCalls(messages) const text = latestToolText(messages) @@ -98,7 +109,7 @@ function implementer(messages) { write_scopes: ['implementation'], } }]) } - if (last.includes('wait_agent')) { + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) { return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) @@ -109,10 +120,10 @@ function implementer(messages) { task_id: 'task-2', expected_revision: 1, action: 'claim', } }]) } - if (last.includes('team_task_get')) { - return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) + if (last.includes('team_task_get') && !hasNewCoordinationInput(messages)) { + return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) } - if (last.includes('wait_agent')) { + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) { return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) } return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }]) @@ -123,7 +134,7 @@ function implementer(messages) { if (!names.includes('send_message')) { return toolChunks([ { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } }, - { name: 'send_message', args: { target: 'lead', message: 'Implementation complete and verified.' } }, + { name: 'send_message', args: { agent_id: parentId, message: 'Implementation complete and verified.' } }, ]) } return textChunks('Implementation teammate complete.') @@ -138,26 +149,22 @@ function lead(messages) { script: 'return await agent("TEAM_WORKFLOW_CHILD");', } }]) } - const spawned = names.filter(name => name === 'spawn_teammate').length + const spawned = names.filter(name => name === 'subagent').length if (spawned === 0) { return toolChunks([{ - name: 'spawn_teammate', + name: 'subagent', args: { - name: 'implementer', description: 'Own deterministic implementation.', prompt: 'IMPLEMENTER_MARK: wait for research, complete dependent task 2, report to lead.', - context: 'fresh', }, }]) } if (spawned === 1) { return toolChunks([{ - name: 'spawn_teammate', + name: 'subagent', args: { - name: 'researcher', description: 'Own deterministic research.', prompt: 'RESEARCHER_MARK: complete research task 1, message implementer, then finish.', - context: 'fresh', }, }]) } @@ -165,35 +172,38 @@ function lead(messages) { if (last.includes('team_task_list')) { const completed = result.match(/"status":"completed"/gu)?.length ?? 0 if (completed >= 2) return toolChunks([{ name: 'list_agents', args: {} }]) - return toolChunks([{ name: 'team_task_list', args: {} }]) + return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'team_task_list' : 'wait_agent', + args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) } if (last.includes('list_agents')) { - const inactive = result.match(/"status":"inactive"/gu)?.length ?? 0 + const inactive = result.split('\n') + .filter(row => row.includes('— Own deterministic ') && row.includes('[inactive]')).length if (inactive >= 2) return textChunks('TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.') - return toolChunks([{ name: 'list_agents', args: {} }]) + return toolChunks([{ name: hasNewCoordinationInput(messages) ? 'list_agents' : 'wait_agent', + args: hasNewCoordinationInput(messages) ? {} : { timeout_ms: 10000 } }]) } - if (last.includes('wait_agent')) return toolChunks([{ name: 'team_task_list', args: {} }]) + if (last.includes('wait_agent') || hasNewCoordinationInput(messages)) return toolChunks([{ name: 'team_task_list', args: {} }]) return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }]) } class TeamFixtureAdapter extends LlmAdapter { async * stream(options) { const tools = options.tools.map(tool => tool.name) - if (tools.includes('subagent') || tools.includes('subagent_fork')) { - throw new Error('Team profile exposes a direct subagent tool') - } const initial = options.messages.findLast(message => message.role === 'user' && message.source.kind === 'user') const workflowChild = initial?.content[0]?.text === 'TEAM_WORKFLOW_CHILD' const context = options.messages.findLast(message => message.source?.kind === 'runtime-context') - const identity = context?.source.sections?.find(section => section.name === 'team:identity')?.text - if (!workflowChild && (!tools.includes('spawn_teammate') || !tools.includes('workflow'))) { - throw new Error('Team profile is missing teammate or workflow tools') + const section = name => context?.source.sections?.find(candidate => candidate.name === name)?.text + const role = section('subagent:identity') + const parentId = role?.match(/You are a subagent delegated by agent "([^"]+)"/u)?.[1] + const task = initial?.content.filter(block => block.type === 'text').map(block => block.text).join('\n') + if (!workflowChild && (!tools.includes('subagent') || !tools.includes('workflow') || !tools.includes('team_task_create'))) { + throw new Error('Team profile is missing delegation, workflow, or task tools') } let chunks if (workflowChild) chunks = textChunks('Fresh workflow child complete.') - else if (identity?.startsWith('You are teammate "researcher".')) chunks = researcher(options.messages) - else if (identity?.startsWith('You are teammate "implementer".')) chunks = implementer(options.messages) - else if (identity?.startsWith('You are the Team Lead named "lead".')) chunks = lead(options.messages) + else if (task?.startsWith('RESEARCHER_MARK:')) chunks = researcher(options.messages) + else if (task?.startsWith('IMPLEMENTER_MARK:')) chunks = implementer(options.messages, parentId) + else if (role?.startsWith('You are the root agent. Your agent id is')) chunks = lead(options.messages) else throw new Error('Team runtime context is missing a recognized identity') for (const chunk of chunks) { options.signal?.throwIfAborted() @@ -205,9 +215,30 @@ class TeamFixtureAdapter extends LlmAdapter { /** Cordis plugin name. */ export const name = 'team-fixture-llm' /** LLM registry dependency. */ -export const inject = ['llm'] +export const inject = ['llm', 'tools', 'agentTeams'] /** Register the keyless adapter on the shipped default provider route. */ export function apply(ctx) { + ctx.on('agent/created', async ({ agent }) => { + if (agent.session.header.parentSession === undefined) await ctx.agentTeams.setEnabled(agent, true) + }) + const implementerWaiting = Promise.withResolvers() + const leadWaiting = Promise.withResolvers() + for (const gate of [implementerWaiting, leadWaiting]) void gate.promise.catch(() => undefined) + ctx.effect(() => () => { + implementerWaiting.reject(new Error('Team fixture disposed before implementer wait')) + leadWaiting.reject(new Error('Team fixture disposed before lead wait')) + }) + ctx.on('tools/execute', async (exec, next) => { + if (exec.name === 'team_task_create' && exec.arguments.subject === 'Research') { + await Promise.all([implementerWaiting.promise, leadWaiting.promise]) + } + const result = next() + if (exec.name === 'wait_agent') { + if (exec.agent.session.header.origin === 'subagent') implementerWaiting.resolve() + else leadWaiting.resolve() + } + return result + }) ctx.llm.registerAdapter(['deepseek-official'], new TeamFixtureAdapter()) } diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index a2ae0c9f19..9f53fb980a 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -1093,7 +1093,7 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { + it('runs a keyless Agent Team with described teammates, sibling messages, dependent tasks, waiting, and Lead aggregation', async () => { let projection: unknown const result = await runLoaderSmoke({ label: 'Agent Teams headless snapshot', @@ -1125,8 +1125,8 @@ describe('headless stream-json snapshots', () => { expect(workflowRows.filter(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'user').map(row => row.data)) .toEqual([expect.objectContaining({ content: [{ type: 'text', text: 'TEAM_WORKFLOW_CHILD' }] })]) - const members = rows.filter(row => row.type === 'team/member') - .map(row => ((row.data as JsonObject).member as JsonObject)) + const teammates = rows.filter(row => row.type === 'subagent/catalog') + .flatMap(row => typeof (row.data as JsonObject).label === 'string' && ((row.data as JsonObject).label as string).startsWith('Own deterministic ') ? [(row.data as JsonObject).label as string] : []) const tasks = rows.filter(row => row.type === 'team/task') .map(row => ((row.data as JsonObject).task as JsonObject)) const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task]))) @@ -1179,21 +1179,51 @@ describe('headless stream-json snapshots', () => { && ((row.data as JsonObject).source as JsonObject).kind === 'user') if (initial === undefined) throw new Error('Teammate Session has no initial task') const content = (initial.data as JsonObject).content as JsonObject[] - expect(content).toHaveLength(1) + // The task block is followed by the standard return guidance naming the Lead. + expect(content).toHaveLength(2) const context = parseJsonl(log.content).find(row => row.type === 'user/message' && ((row.data as JsonObject).source as JsonObject).kind === 'runtime-context') const source = (context?.data as JsonObject)?.source as JsonObject const sections = source?.sections as JsonObject[] | undefined - const identity = sections?.find(section => section.name === 'team:identity')?.text - if (typeof identity !== 'string') throw new Error('Teammate runtime context has no identity') - return [identity] + const leadId = parent.header.id as string + const text = (name: string) => { + const value = sections?.find(section => section.name === name)?.text + if (typeof value !== 'string') throw new Error(`Teammate runtime context has no ${name}`) + return value.replaceAll(log.header.id as string, '').replaceAll(leadId, '') + } + expect(sections?.some(section => section.name === 'team:identity')).toBe(false) + return [text('subagent:identity')] }).sort() + const allRows = logs.flatMap(log => parseJsonl(log.content)) + const messageSources = allRows.flatMap((row) => { + const data = row.data as JsonObject + if (row.type === 'user/message') return [(data.source as JsonObject).kind] + if (row.type === 'agent/inbox/spliced') { + return (data.inserted as JsonObject[]).map(message => (message.source as JsonObject).kind) + } + return [] + }) + expect(messageSources).not.toContain('team-task-change') + const waitIds = new Set(allRows.filter(row => row.type === 'tool/call' + && (row.data as JsonObject).name === 'wait_agent').map(row => (row.data as JsonObject).callId)) + const waitResults = allRows.filter((row) => { + if (row.type !== 'tool/result') return false + const message = (row.data as JsonObject).message as JsonObject + return waitIds.has((message.source as JsonObject).callId) + }) + expect(waitResults).toHaveLength(waitIds.size) + for (const row of waitResults) { + const message = (row.data as JsonObject).message as JsonObject + expect(message.isError).not.toBe(true) + const content = message.content as JsonObject[] + expect(JSON.parse(content.filter(block => block.type === 'text').map(block => block.text).join(''))) + .toEqual({ timedOut: false }) + } projection = { sessions: logs.length, workflowStopReason: (rows.find(row => row.type === 'tool-workflow/run-end')?.data as JsonObject)?.stopReason, - memberEdges: members.length, identities, - activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), + teammates: [...teammates].sort(), tasks: latestTasks.map(task => ({ subject: task.subject, revision: task.revision, @@ -1203,7 +1233,7 @@ describe('headless stream-json snapshots', () => { deliveredMessages: rows.filter(row => row.type === 'team/message/delivered').length, waited: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'wait_agent'), - checkedRoster: rows.some(row => row.type === 'tool/call' + checkedAgents: rows.some(row => row.type === 'tool/call' && (row.data as JsonObject).name === 'list_agents'), steerEvidence: { nextStepInbox: steeredInboxIndex >= 0, @@ -1221,25 +1251,22 @@ describe('headless stream-json snapshots', () => { }) expect(projection).toMatchInlineSnapshot(` { - "activeMembers": [ - "implementer", - "researcher", - ], - "checkedRoster": true, + "checkedAgents": true, "deliveredMessages": 0, "identities": [ - "You are teammate "implementer". - Your Team Lead is named "lead". - To message your Team Lead, use send_message({ target: "lead", message: "..." }). - Use list_agents({}) to find your teammates and their names. - To message another teammate, use send_message({ target: "", message: "..." }).", - "You are teammate "researcher". - Your Team Lead is named "lead". - To message your Team Lead, use send_message({ target: "lead", message: "..." }). - Use list_agents({}) to find your teammates and their names. - To message another teammate, use send_message({ target: "", message: "..." }).", + "You are a subagent delegated by agent "". + Your agent id is "". + The root agent of your collaboration tree is "". + + To message other agents, use send_message({ agent_id: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their ids.", + "You are a subagent delegated by agent "". + Your agent id is "". + The root agent of your collaboration tree is "". + + To message other agents, use send_message({ agent_id: "", message: "..." }). + Use list_agents({}) to find your collaborator agents and their ids.", ], - "memberEdges": 4, "queuedMessages": 0, "sessions": 4, "steerEvidence": { @@ -1260,6 +1287,10 @@ describe('headless stream-json snapshots', () => { "subject": "Research", }, ], + "teammates": [ + "Own deterministic implementation.", + "Own deterministic research.", + ], "waited": true, "workflowStopReason": "completed", } diff --git a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts index 941d95c31d..3aff474038 100644 --- a/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts @@ -1,6 +1,6 @@ /** * Assembled-app regression: a parent-owned catalog event remains sufficient - * for direct discovery when the persisted child has no descriptor event. + * for descendant discovery when the persisted child has no descriptor event. */ import { readFile, readdir, writeFile } from 'node:fs/promises' @@ -42,7 +42,7 @@ async function expectSession(actual: string, expectedPath: string): Promise { const ctx = new Context() @@ -106,7 +106,7 @@ async function seedCataloguedDescriptorlessChild(root: string, cwd: string): Pro } describe('parent-owned cold child catalog snapshot', () => { - it('discovers a catalogued child without reading its descriptor through the assembled headless app', async () => { + it('discovers a descriptorless catalogued child with its tree position through the assembled headless app', async () => { let cwd = '' const result = await runLoaderSmoke({ label: 'subagent diagnostic headless stream-json snapshot', @@ -131,9 +131,8 @@ describe('parent-owned cold child catalog snapshot', () => { const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"')) if (parent === undefined) throw new Error('missing persisted parent log') - // THE model-visible fact: the parent catalog supplies discovery even - // though consulting the descriptor-less child would classify it corrupt. - expect(parent).toContain(`${childId} [inactive] — descriptorless child`) + // Discovery reads child catalogs without requiring activation descriptors. + expect(parent).toContain(`${childId} [inactive] parent=${parentId} depth=1 — descriptorless child`) expect(parent).not.toContain('[diagnostic: corrupt]') const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } diff --git a/apps/cli/tests/profiles/web/tests/default-web-process.ts b/apps/cli/tests/profiles/web/tests/default-web-process.ts index 537ac77702..155f8d6670 100644 --- a/apps/cli/tests/profiles/web/tests/default-web-process.ts +++ b/apps/cli/tests/profiles/web/tests/default-web-process.ts @@ -55,7 +55,7 @@ export async function withDefaultWeb( await writeFile(patch, JSON.stringify([{ insert: [{ id: 'runtime-roster-observer', name: pathToFileURL(join(root, 'fixtures/runtime-roster-observer.js')).href, - config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-agent-team/lib/index.js')).href }, + config: { negativeControl: pathToFileURL(join(repoRoot, 'packages/experimental/client-ui-voice-input/lib/index.js')).href }, }] }]) + '\n') const launch = resolveExampleLaunch({ srcBin: join(repoRoot, 'apps/cli/src/bin.ts'), diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 7563a323aa..687afcb6d8 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -7,7 +7,7 @@ import { modulePackage } from './runtime-roster.ts' import { experimentalRuntimeReferences } from './runtime-roster-isolation.ts' import { withDefaultWeb, webGet } from './default-web-process.ts' -const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' +const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-voice-input' it('boots default Web with the shipped Schedule rows, without experimental modules, time context, or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { @@ -37,6 +37,9 @@ it('boots default Web with the shipped Schedule rows, without experimental modul // The shipped composition mounts the Host Schedule service and its task // page; the clock stays preset-level, so neither plane carries a row for it. expect(roster.entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: '@deepseek-ai/dsh-agent-team', state: FiberState.ACTIVE }), + expect.objectContaining({ name: '@deepseek-ai/dsh-tool-agent-team', state: FiberState.ACTIVE }), + expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-agent-team', state: FiberState.ACTIVE }), expect.objectContaining({ name: '@deepseek-ai/dsh-schedule', state: FiberState.ACTIVE }), expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-schedule', state: FiberState.ACTIVE }), ])) @@ -50,7 +53,7 @@ it('boots default Web with the shipped Schedule rows, without experimental modul const mounted = contaminated.plugins.filter(plugin => plugin.modules.some(url => modulePackage(url) === experimentalName)) expect(mounted).toEqual([expect.objectContaining({ state: FiberState.ACTIVE })]) expect(experimentalRuntimeReferences(contaminated)).toEqual(expect.arrayContaining([ - expect.stringContaining('/packages/experimental/client-ui-agent-team/'), + expect.stringContaining('/packages/experimental/client-ui-voice-input/'), ])) }) }) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index e7aa033223..ff5fd57cae 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -248,9 +248,9 @@ beforeAll(async () => { }, 120_000) describe('the shipped Web composition', () => { - it('exposes only the directory tool in the global layer', () => { - // Directory selection is global; action tools belong to their Agent presets. - expect(toolNames(ctx)).toEqual(['working_directory']) + it('registers the directory and conditional Team tools in the global layer', () => { + // Team registrations are shared; session mode controls their step-time availability. + expect(toolNames(ctx)).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) }) it('keeps the token meter and its context-meter projections on the host plane', async () => { @@ -304,7 +304,7 @@ describe('the shipped Web composition', () => { 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'send_message', 'skill', - 'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search', + 'subagent', 'subagent_fork', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search', 'workflow', 'working_directory', 'write', ]) expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() @@ -408,7 +408,7 @@ describe('the shipped Web composition', () => { expect(assembly.sections).toEqual([ { name: 'deployment:persona-prefix', text: MINIMAL_PROMPT }, ]) - expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'working_directory']) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined() // serviceFor reports preset-owned providers; unisolated consumers inherit the host fs. @@ -485,7 +485,7 @@ describe('the shipped Web composition', () => { const last = adapter.requests.at(-1) expect(last?.messages.flatMap(message => message.content) .some(block => block.type === 'text' && block.text.includes('Preserve the requested project task.'))).toBe(true) - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -529,7 +529,7 @@ describe('the shipped Web composition', () => { setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, minimal.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) expect(ctx.agentPresets.serviceFor(minimal.agent, 'compaction')) .not.toBe(ctx.agentPresets.serviceFor(full.agent, 'compaction')) @@ -538,7 +538,7 @@ describe('the shipped Web composition', () => { // Tearing the minimal session down leaves the full one whole. expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10) - expect(toolNames(ctx)).toEqual(['working_directory']) + expect(toolNames(ctx)).toEqual(['team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await full.dispose() } @@ -697,7 +697,7 @@ describe('the shipped Web composition', () => { // stays the preset's choice — minimal mounts no `tool-skill`, so its // tool table has no loader even though the global layer is readable. expect((await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name)).toContain('dsh-badge') - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -790,7 +790,7 @@ describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surfac const productCtx = await bootSelected(installed) const spawn = vi.spyOn(productCtx.subprocess, 'spawn') try { - expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort()) + expect(products.filter(name => productCtx.subagents.getProvider(name) !== undefined).sort()) .toEqual([...installed].sort()) const expected = installed.map(toolName).sort() for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) { @@ -881,6 +881,8 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. + const spills = vi.spyOn(copyCtx.spillStore, 'saveText') + onTestFinished(() => { spills.mockRestore() }) const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', @@ -889,11 +891,8 @@ describe('a user preset declared from the shipped cordis rows', () => { agent: copied.agent, }) expect(queried.isError).toBe(false) - // The whole-table answer can pass the inline token budget once the - // preset's tool table grows, and the spill policy then replaces its - // middle with a gap plus a spill footer. What this case proves is the - // provider's answer, so assert the names it must carry. - const queriedText = resultText(queried) + // The tool catalog may exceed the inline budget; assert its complete stored text. + const queriedText = spills.mock.calls.at(-1)?.[0].content ?? resultText(queried) for (const toolName of ['bash', 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']) { expect(queriedText).toContain(`"name": "${toolName}"`) } @@ -1088,7 +1087,7 @@ describe('the default preset as a user setting', () => { }) try { // An omitted preset id resolves the stored default instead of the composed default. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } @@ -1134,7 +1133,7 @@ describe('a profile patch stored before Developer tools owned preset selection', setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined), }) try { - expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'working_directory']) + expect(toolNames(legacy, handle.agent)).toEqual(['bash', 'read_image', 'team_task_create', 'team_task_get', 'team_task_list', 'team_task_update', 'working_directory']) } finally { await handle.dispose() } diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 3dfceaccd1..899796c03a 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -15,8 +15,8 @@ en: 127ea90553cb6af7 zh: 7b1c5aec8558c684 /deepseek-harness-desktop/application-overview: - en: 1667e3d6ffddea4a - zh: 4661a42b9ef4f673 + en: d4afa46403997884 + zh: 9b9fe126afda70f0 /deepseek-harness-desktop/terminal-command: en: 5f2d25a1d8472647 zh: 2acb5c5d7d1917e2 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fe24b24568..bf36a7b9fe 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -35,7 +35,7 @@ Electron caches the last confirmed UI language in `userData/desktop/locale-cache Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited. -The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. +The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it. Native Host requests send the current Host's authentication cookie explicitly, and no Electron session stores it; each Host start removes `dsh-auth-*` cookies that earlier releases left in the default session, because cookies ignore ports and would otherwise accompany every request to `127.0.0.1`. Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 933e329ce1..6241072116 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -35,7 +35,7 @@ Electron 在 `userData/desktop/locale-cache` 缓存最近确认的界面语言 桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。 -桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。 +桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。原生 Host 请求会显式发送当前 Host 的认证 Cookie,任何 Electron session 都不保存该 Cookie;每次启动 Host 时都会移除早期版本遗留在默认 session 中的 `dsh-auth-*` Cookie,因为 Cookie 不区分端口,否则这些 Cookie 会随每个发往 `127.0.0.1` 的请求一并发送。 Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index c39bb71bc3..49412773f7 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,17 +27,17 @@ en: 253e17998951931c zh: b582b377391d2ec2 /desktop-packaging/macos-self-hosted-packaging: - en: f4cc5209bdaeefba - zh: 1db959d1ed345fcc + en: b7e03e2e9807a62c + zh: e5b0c43910e085d8 /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 /desktop-packaging/local-macos-dmg-without-release-signing: - en: 2feaa0412247d81f - zh: 461dde8e13fd4734 + en: 1146bbc11be43960 + zh: 7c13543a0513c059 /desktop-packaging/macos-release-signing-and-diagnostics: - en: 29fa6bd8919f3ec0 - zh: 9e31e037fa3d9378 + en: a0b4aa1080ecf6fb + zh: 37d4b8934f89bee8 /desktop-packaging/directory-builds-and-preparation: en: 5860c306375ddd63 zh: a76e3cdbbe25c79e @@ -45,11 +45,11 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 83e1075417039410 - zh: 3088a7ae0a2cc3c5 + en: 44044a5c14b75533 + zh: 39d91a369c4ec2fa /desktop-packaging/desktop-runtime-preparation: - en: ab73b67d899e611d - zh: d77b0525c6b81e91 + en: 84be38ab3136fff7 + zh: bb4ed12ee0e1dfa0 /desktop-packaging/installation-and-release-verification: en: eb35eb973b796643 zh: c1c52311632b7296 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 28aa9ff9c7..502a95e2be 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -125,7 +125,7 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. +The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing and cache measurement options follow these common fields. @@ -172,19 +172,21 @@ pnpm run package:desktop:mac:x64:unsigned Add `--check` to validate local configuration and tools without building, or `--dir` to stop at the unpacked application. The DMG is `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`. After all checks pass, the command prints `DMG:` followed by its absolute path. Open it and copy the application to a local test directory. -These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. +These commands ignore `.env.macos` and inherited release settings, use `com.deepseek.harness` as the app ID, and omit automatic-update and mandatory-update configuration. Build tools, standard network proxies, and `DSH_DESKTOP_NPM_REGISTRY` remain available from the shell. They skip Developer ID signing, Apple notarization, temporary signing keychains, and notarization proxy changes. Electron receives a local ad-hoc signature without certificates so the modified executable can run; the DMG is unsigned. Both preparation and the assembled application still run runtime integrity and smoke checks. Local builds omit update feeds, ZIPs, and release completion records, and the upload commands cannot publish them. macOS may require approval in System Settings → Privacy & Security to open an unnotarized application. These builds stage no Computer Use helper, so Computer Use reports the missing helper when it prepares. ## macOS release signing and diagnostics -The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. +The signed macOS configuration uses the required release environment instead of accepting whichever certificate appears first in a keychain. It rejects empty values, a malformed Team ID, a signing identity that includes electron-builder's unsupported `Developer ID Application:` prefix, and incomplete notarization credentials. Signed macOS packaging requires the configured identity and its private key. Runtime preparation applies that identity, a secure timestamp, and hardened runtime to every embedded Mach-O file outside the notarized Computer Use helper bundles; for those bundles it verifies the deep signature, release identity, stapled ticket, and Gatekeeper acceptance without re-signing their files. A signed local build of a source that ships Computer Use therefore needs the notarized helper staged first with `scripts/cua-helper/package.ts` and `stage.ts`; an ad-hoc `build:native` output under `native/bin` fails this verification; after signing the application, a deep strict check rejects any other leaf authority or Team ID before artifact creation. The signed fixed-target macOS installer commands create separate copies of the signed application and run two artifact lanes concurrently. One lane notarizes and staples the App before generating the ZIP and its update metadata. The other encloses its signed App copy in a signed DMG, then notarizes, staples, and verifies the DMG; its inner App has no individually stapled ticket. Both lanes must finish successfully before their artifacts reach the final directory and the release completion record is written. Signed directory-only commands also require notarization credentials and wait for Apple notarization and App stapling. The [parallel notarization decision](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md) owns copy isolation and container ticket semantics. `CSC_LINK` must name a local p12 containing the Developer ID Application certificate and private key; URLs and Base64 inputs are not supported. `CSC_KEY_PASSWORD` is its export password, not an Apple account or login password; an explicitly empty value supports an unencrypted p12. Before building, packaging creates and unlocks a private temporary keychain, imports the p12, authorizes signing, and signs a small probe. Runtime and App signing use this keychain explicitly; existing login keychains require no setup or manual unlocking. Child processes receive its path without the p12 password. The keychain is deleted after success or ordinary failure; CI must clean temporary credentials after forced termination. Ephemeral CI writes the certificate and `.env.macos` from its secret store, restricts file access, and deletes both after the job; self-hosted machine presets retain private files outside the checkout. Ambient `CSC_NAME` and certificate discovery order do not select the release owner. Notary credentials may instead use electron-builder's complete Apple ID or keychain-profile strategy. The two macOS identity variables are also required when repeating the application check manually with `pnpm --dir apps/desktop run verify:mac-signature -- `. macOS signing visits real files without following Framework symlink aliases. PAK resources retain all shipped languages and are sealed by the enclosing Framework or application signature instead of receiving individual signatures. The [release policy](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) owns the dependency patch and verification requirements. macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets//signature-cache` unless `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` selects external storage. An explicit path must be absolute and already exist, contain no linked ancestors, and be owned by the current account with mode `0700` and read/write/search access; invalid or empty settings fail preflight without fallback. Omit the field to retain target-local storage. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts. -Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. +Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted, and environments are not recorded. Apple command failures retain their exit code, signal, stdout and stderr in redacted form; the adapter also writes these details to stderr when no journal is available. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together. + +CUA helper packaging prints `CUA_PACKAGING_RECORD` and creates its own record in the same directory before desktop installer startup. Its `run.json` identifies the helper target, product version and source commit; `events.jsonl` retains command and notarization phases, submission IDs and Apple results. `result.json` records success or failure. These records survive temporary payload cleanup, and the Desktop Actions diagnostic upload includes them even when helper preparation fails. App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log. @@ -246,7 +248,7 @@ Desktop packs workspace packages locally and installs external dependencies thro The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. -The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). +The [Office conversion provider](../../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). The Computer Use helper applications under `@deepseek-ai/dsh-experimental-cua-macos/native/bin` are also unpacked whole under `resources/app.asar.unpacked/dsh/node_modules/`, and the provider opens that copy. The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](../tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](../tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](../scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. @@ -280,7 +282,7 @@ Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration. -Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. +Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files, and verify the notarized Computer Use helper bundles without re-signing them, before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment. Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 2e87933826..7f43a0c2e5 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -125,7 +125,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量选项位于公共字段之后。 @@ -172,19 +172,21 @@ pnpm run package:desktop:mac:x64:unsigned 添加 `--check` 仅验证本地配置和工具而不构建,或添加 `--dir` 只生成解包应用。DMG 路径为 `apps/desktop/.desktop-build/targets//unsigned-artifacts/deepseek-harness--mac--unsigned.dmg`。所有检查通过后,命令打印 `DMG:` 及其绝对路径。打开后将应用复制到本地测试目录。 -这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。 +这些命令忽略 `.env.macos` 和继承的发布配置,使用 `com.deepseek.harness` 作为应用 ID,并省略自动更新和强制更新配置。构建工具、标准网络代理与 `DSH_DESKTOP_NPM_REGISTRY` 仍可从 shell 提供。它们跳过 Developer ID 签名、Apple 公证、临时签名钥匙串和公证代理修改。Electron 使用无需证书的本地 ad-hoc 签名,以便修改后的可执行文件能够运行;DMG 不签名。准备阶段和组装后的应用仍执行运行时完整性与冒烟检查。本地构建不生成更新源、ZIP 或发布完成记录,上传命令无法发布这些产物。macOS 可能要求在“系统设置 → 隐私与安全性”中批准打开未经公证的应用。这类构建不放入 Computer Use helper,Computer Use 准备时会报告 helper 缺失。 ## macOS 发布签名与诊断 -macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到每个内嵌 Mach-O 文件;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 +macOS 签名配置使用必填发布环境,不会接受钥匙串中最先发现的证书。空值、格式错误的 Team ID、包含 electron-builder 不支持的 `Developer ID Application:` 前缀的签名身份,以及不完整的公证凭据都会被拒绝。macOS 签名打包要求已配置的身份及其私钥可用。运行时准备会把该身份、安全时间戳与 hardened runtime 应用到已公证 Computer Use helper 应用包之外的每个内嵌 Mach-O 文件;对这些应用包,则校验深度签名、发布身份、钉票和 Gatekeeper 评估结果,不重签其中的文件。因此,打包会随附 Computer Use 的源码时,本地签名构建需要先用 `scripts/cua-helper/package.ts` 和 `stage.ts` 放入已公证的 helper;`native/bin` 下由 `build:native` 生成的临时签名产物无法通过此校验;应用签名完成后,深度严格检查会拒绝其他叶证书 Authority 或 Team ID,验证通过才生成发布产物。macOS 签名固定目标安装包命令为已签名应用创建独立副本,并发执行两条产物流。一路先公证 App 并钉票,再生成 ZIP 及其更新元数据。另一路把已签名 App 副本封装进签名 DMG,再公证 DMG、钉票并验证;其中的 App 不单独附加票据。只有两路均成功结束,产物才会移入最终目录并写入发布完成记录。签名的仅目录命令同样需要公证凭据,并等待 Apple 公证和 App 钉票完成。[并行公证决策](../../../.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md)负责副本隔离与容器票据语义。`CSC_LINK` 必须指向包含 Developer ID Application 证书及私钥的本地 p12,不支持 URL 或 Base64 输入。`CSC_KEY_PASSWORD` 是其导出密码,不是 Apple 账号或登录密码;未加密的 p12 可显式填写空值。构建前,打包流程自动创建并解锁私有临时钥匙串、导入 p12、授权签名并签署小型探针。运行时与 App 签名显式使用该钥匙串,无需预先配置或手动解锁登录钥匙串。子进程只接收钥匙串路径,不接收 p12 密码。成功或普通失败后删除临时钥匙串;强制终止后由 CI 清理临时凭据。临时 CI 从密钥存储生成证书文件和 `.env.macos`,限制文件访问权限,并在作业结束后删除二者;自托管机器 preset 在 checkout 外保留私有文件。环境中的 `CSC_NAME` 与证书发现顺序都不能选择发布所有者。公证凭据也可以使用 electron-builder 支持的完整 Apple ID 或钥匙串 profile 方式。手动执行 `pnpm --dir apps/desktop run verify:mac-signature -- ` 重复应用检查时,也必须提供两个 macOS 身份变量。 macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK 资源保留全部随附语言,由外层 Framework 或应用签名记录完整性,不逐个签名。[发布策略](../../../.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)负责依赖补丁和验证要求。 macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets//signature-cache`,除非通过 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 选择外置存储。显式路径必须是已存在的绝对目录,祖先路径不能包含链接,目录须归当前账户所有、权限为 `0700` 且可读写及遍历;非法或空配置在预检时失败,不回退。省略该字段保留目标本地缓存。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。 -Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 +Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖,不记录环境变量全集。Apple 命令失败时保留脱敏后的退出码、信号、stdout 和 stderr;没有日志目录时,适配器也会将这些详情写入 stderr。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。 + +CUA helper 打包通过 `CUA_PACKAGING_RECORD` 输出同一目录下的独立记录路径,在桌面安装包开始构建前创建。其 `run.json` 记录 helper 目标、产品版本和源码提交,`events.jsonl` 保留命令与公证阶段、已返回的提交 ID 和 Apple 处理结果,`result.json` 记录成功或失败。这些记录不会随临时 payload 清理而删除;即使 helper 准备失败,Desktop Actions 的诊断上传步骤也会收集它们。 App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。 @@ -246,7 +248,7 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安 [Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 -[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 +[Office 转换提供方](../../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。`@deepseek-ai/dsh-experimental-cua-macos/native/bin` 下的 Computer Use helper 应用也整体解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下,提供方打开该副本。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](../tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](../tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](../scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 @@ -280,7 +282,7 @@ Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补 Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。 -每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 +每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,并在不重签的前提下校验已公证的 Computer Use helper 应用包,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。 Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index cf0c19e895..0e12399d89 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -76,7 +76,10 @@ export function createElectronBuilderConfig( let dshDestination let windowsCode = [] const unpack = ['**/*.{node,dylib,dll,so,exe}', '**/*.so.*', '**/spawn-helper', '**/@vscode/ripgrep-*/bin/rg', - `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`] + `**/node_modules/@deepseek-ai/libreoffice-kit-${resolvedPlatform}-${resolvedArch}/**/*`, + // LaunchServices opens the Computer Use helper as an application, so every file of its bundles stays on disk. + // Both architectures ship: they form one notarized payload whose manifest staging verifies as a whole. + '**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*'] const windowsSigner = packagesWindows && !unsigned ? createWindowsTokenSigner({ certificateFile: env.DSH_DESKTOP_WINDOWS_CER_FILE, diff --git a/apps/desktop/scripts/logged-notarytool.mjs b/apps/desktop/scripts/logged-notarytool.mjs index ae2a9bf055..a9ed964b3c 100755 --- a/apps/desktop/scripts/logged-notarytool.mjs +++ b/apps/desktop/scripts/logged-notarytool.mjs @@ -5,7 +5,7 @@ import { basename, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { recordPackagingEvent, packagingOutputRedactor } from './packaging-run.mjs' -import { packagingStep } from './packaging-step.mjs' +import { packagingErrorDetails, packagingStep } from './packaging-step.mjs' const exec = promisify(execFile) const AUTH_FLAGS = new Set(['--apple-id', '--password', '--team-id', '--key', '--key-id', '--issuer', '--keychain', '--keychain-profile']) @@ -17,7 +17,8 @@ async function execute(args) { } catch (error) { // Apple can report rejection with a nonzero exit; electron-notarize needs its JSON ID to retrieve diagnostics. if (args[0] === 'wait' && typeof error.code === 'number' && typeof error.stdout === 'string' && error.stdout.trim()) return error.stdout - throw error + throw new Error(`notarytool ${args[0]} failed (code: ${String(error.code ?? 'unknown')}, signal: ${String(error.signal ?? 'none')})\n` + + `stdout: ${error.stdout ?? ''}\nstderr: ${error.stderr ?? ''}`, { cause: error }) } } @@ -74,10 +75,13 @@ export async function runLoggedNotarytool(args, directory, invoke = execute) { } if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { - try { process.stdout.write(await runLoggedNotarytool(process.argv.slice(2), process.env.DSH_DESKTOP_PACKAGING_RUN_DIR)) } - catch { - // execFile errors include credential-bearing argv; detailed failures are redacted in the journal. - process.stderr.write('desktop notarization: Apple command failed; see packaging events.jsonl\n') + const args = process.argv.slice(2) + const directory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + try { process.stdout.write(await runLoggedNotarytool(args, directory)) } + catch (error) { + const credentials = args.filter((_, index) => AUTH_FLAGS.has(args[index - 1])) + process.stderr.write(`desktop notarization: Apple command failed${directory ? `; see ${directory}/events.jsonl` : ''}\n` + + `${packagingErrorDetails(error, credentials)}\n`) process.exitCode = 1 } } diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts index 1d4c27c0d5..16b8fe2a27 100644 --- a/apps/desktop/scripts/macos-runtime.ts +++ b/apps/desktop/scripts/macos-runtime.ts @@ -8,9 +8,15 @@ import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' import { cachedMacOSSignature, pruneMacOSSignatureCache } from './macos-signature-cache.ts' import { macOSCachePolicy } from './macos-cache-policy.ts' -import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' +import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca']) +/** + * The notarized Computer Use helper bundle containing a runtime path, as staged by `scripts/cua-helper/stage.ts`: + * `native/bin/darwin-//.app`. Other bundles, such as LibreOffice's unsigned application, + * are not matched and keep per-file runtime signing. + */ +const CUA_HELPER_BUNDLE = /^(node_modules\/@deepseek-ai\/dsh-experimental-cua-macos\/native\/bin\/darwin-[^/]+\/[^/]+\/[^/]+\.app)\//u function magic(path: string): string { const descriptor = openSync(path, 'r') @@ -33,21 +39,32 @@ async function primaryRuntimeFiles(root: string): Promise { } /** - * Sign and verify every materialized Mach-O file, awaiting all signers on failure. + * Sign and verify every materialized Mach-O file, awaiting all signers on failure. Files inside the notarized Computer + * Use helper bundles are not re-signed, because signing them breaks the bundle seal and changes the identity macOS + * grants permissions to; each such bundle's deep signature, release identity, stapled ticket, and Gatekeeper + * acceptance are verified before any file is signed. * @param root - Self-contained production runtime. * @param appId - Release application identifier. * @param expected - Required signing identity. * @param arch - Target runtime architecture, independent of the signing host. * @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe. * @param kind - Primary payloads admit validated Python aliases; Desktop application runtimes reject every link. - * @returns Number of signed native files. + * @returns Number of signed native files, excluding files inside verified Computer Use helper bundles. + * @throws When a helper bundle fails verification, or when any signer fails. */ export async function signMacOSRuntime( root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string, kind: 'desktop' | 'primary' = 'desktop', ): Promise { const inventory = kind === 'primary' ? await primaryRuntimeFiles(root) : inventoryDesktopRuntime(root).map(file => file.path) - const files = inventory.filter(path => MACH_O_MAGICS.has(magic(join(root, path)))) + const bundles = new Set() + const files: string[] = [] + for (const path of inventory) { + const bundle = CUA_HELPER_BUNDLE.exec(path)?.[1] + if (bundle !== undefined) bundles.add(bundle) + else if (MACH_O_MAGICS.has(magic(join(root, path)))) files.push(path) + } + for (const bundle of [...bundles].sort()) verifyMacOSNotarizedApplication(join(root, bundle), expected) const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '') let hits = 0 let misses = 0 diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts index 862b8f81ed..19ae3fe796 100644 --- a/apps/desktop/src/account-backend.ts +++ b/apps/desktop/src/account-backend.ts @@ -80,10 +80,10 @@ export interface DesktopAccountBackend { * Connect native account operations to the standard authenticated Web backend. * @param origin - Host Web origin. * @param invoke - validated unary RPC caller. - * @param cookies - Electron session cookie reader. + * @param cookie - Host authentication Cookie header for the account WebSocket. * @returns account operations; watch callers own their subscriptions. */ -export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookies: () => Promise): DesktopAccountBackend { +export function desktopAccountBackend(origin: string, invoke: AccountInvoke, cookie: string): DesktopAccountBackend { const call = async (method: string, args: Record = {}): Promise => accountView(await invoke({ namespace: 'account', method, args })) return { @@ -98,34 +98,31 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo const streamId = randomUUID() const expiryStreamId = randomUUID() const analyticsPolicyStreamId = randomUUID() - void cookies().then((cookie) => { - if (closed) return - const url = new URL(REMOTE_STREAM_MUX_PATH, origin) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) - socket.on('open', () => { - if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) - socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) - socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) - }) - socket.on('message', (data) => { - try { - const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) - const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) - if (frame.streamId === analyticsPolicyStreamId) { - if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) - else onAnalyticsEnabledChanged?.(false) - return - } - if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } - if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') - if (frame.type === 'item') listener(accountView(frame.value)) - else socket?.close() - } catch { socket?.close() } - }) - socket.on('error', () => { socket?.close() }) - socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) - }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) + const url = new URL(REMOTE_STREAM_MUX_PATH, origin) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 }) + socket.on('open', () => { + if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } })) + socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } })) + }) + socket.on('message', (data) => { + try { + const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data) + const frame = parseRemoteStreamServerMessage(bytes.toString('utf8')) + if (frame.streamId === analyticsPolicyStreamId) { + if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value) + else onAnalyticsEnabledChanged?.(false) + return + } + if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return } + if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream') + if (frame.type === 'item') listener(accountView(frame.value)) + else socket?.close() + } catch { socket?.close() } + }) + socket.on('error', () => { socket?.close() }) + socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } }) } connect() return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d8d0e30c4c..4c223c3c09 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -16,7 +16,6 @@ import { powerMonitor, nativeImage, nativeTheme, - net, protocol, session, shell, @@ -39,7 +38,7 @@ import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { prepareDesktopUpdateWindow } from './update-window.ts' import { DesktopCommandManager } from './command-management.ts' -import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' +import { serveWebDocument, authenticateWebHost, forwardWebRequest, removeLegacyHostCookies } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' import { pruneCrashReports, RendererConsoleTail, writeCrashReport, type CrashReportSource } from './crash-report.ts' import { OperationJournal } from './operation-journal.ts' @@ -529,11 +528,14 @@ async function main(): Promise { return { start: async () => { const ready = await host.start() - hostCookie = await authenticateWebHost(ready.url) + const cookie = await authenticateWebHost(ready.url) + hostCookie = cookie hostUrl = ready.url + void removeLegacyHostCookies(session.defaultSession.cookies, ready.url) + .catch((error: unknown) => { console.error('desktop: legacy Host cookie cleanup failed', error) }) if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections') injections = ready.injections - welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; ')) + welcomeBackend = connectDesktopWelcome(ready.url, cookie) analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false) if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) } stopAccount?.() diff --git a/apps/desktop/src/web-document.ts b/apps/desktop/src/web-document.ts index a74ea7c7ef..15f289e14b 100644 --- a/apps/desktop/src/web-document.ts +++ b/apps/desktop/src/web-document.ts @@ -49,6 +49,26 @@ export async function authenticateWebHost(url: string): Promise { return end < 0 ? cookie : cookie.slice(0, end) } +/** Cookie store operations used to remove legacy Host authentication cookies. */ +export interface HostCookieStore { + get(filter: { url: string }): Promise + remove(url: string, name: string): Promise +} + +/** + * Remove every `dsh-auth-*` cookie that earlier releases left in a cookie store for the Host's hostname. + * Cookies ignore ports, so cookies from earlier random-port Hosts would otherwise travel with every + * request to that hostname until they expire. + * @param cookies - Electron session cookie store. + * @param url - Host URL; cookies for its hostname on every port are removed. + */ +export async function removeLegacyHostCookies(cookies: HostCookieStore, url: string): Promise { + const origin = new URL(url).origin + for (const { name } of await cookies.get({ url: origin })) { + if (name.startsWith('dsh-auth-')) await cookies.remove(origin, name) + } +} + /** * Response headers not relayed to the renderer. `set-cookie` would hand the * Host's authentication cookie to the page's cookie jar, which the shell owns diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts index 54ab46d85e..15b0932cb2 100644 --- a/apps/desktop/src/welcome-backend.ts +++ b/apps/desktop/src/welcome-backend.ts @@ -35,20 +35,18 @@ function record(value: unknown): value is Record { } /** - * Authenticate the native HTTP client through the Web application's launch URL. - * @param authenticatedUrl - URL supplied by the running Desktop Host. - * @param send - Electron session fetch, retaining the Web authentication cookie. + * Connect the native HTTP client to the Desktop Host; every request sends `cookie` explicitly and uses no cookie jar. + * @param hostUrl - URL of the running Desktop Host; only its origin is used. + * @param cookie - Cookie header returned by `authenticateWebHost` for this Host. + * @param send - HTTP client for Host requests. * @returns metadata reads and write-only credential operations over standard RPC. */ -export async function connectDesktopWelcome( - authenticatedUrl: string, - send: (input: string, init?: RequestInit) => Promise, - cookies: () => Promise = () => Promise.resolve(''), -): Promise { - const origin = new URL(authenticatedUrl).origin - const authenticated = await send(authenticatedUrl, { credentials: 'include' }) - await authenticated.body?.cancel() - if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed') +export function connectDesktopWelcome( + hostUrl: string, + cookie: string, + send: (input: string, init?: RequestInit) => Promise = fetch, +): DesktopWelcomeBackend { + const origin = new URL(hostUrl).origin const invoke = async ( request: { namespace: string; method: string; args: Record }, signal?: AbortSignal, @@ -56,8 +54,8 @@ export async function connectDesktopWelcome( const rpcId = randomUUID() const method = `${request.namespace}/${request.method}` const response = await send(new URL(`/api/${method}`, origin).href, { - method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }), - headers: { 'content-type': 'application/json' }, + method: 'POST', redirect: 'error', ...(signal === undefined ? {} : { signal }), + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }), }) if (!response.ok) throw new Error('desktop welcome: Web request failed') @@ -68,7 +66,7 @@ export async function connectDesktopWelcome( } return envelope.result.value } - const account = desktopAccountBackend(origin, invoke, cookies) + const account = desktopAccountBackend(origin, invoke, cookie) const settingsAndReference = async () => { const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} }) if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces') diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts index c239336b22..ffdc59d340 100644 --- a/apps/desktop/tests/account-backend.spec.ts +++ b/apps/desktop/tests/account-backend.spec.ts @@ -1,4 +1,5 @@ import { once } from 'node:events' +import type { IncomingMessage } from 'node:http' import { WebSocketServer } from 'ws' import { expect, it, onTestFinished, vi } from 'vitest' import { accountView, desktopAccountBackend } from '../src/account-backend.ts' @@ -28,7 +29,7 @@ it('uses account Remote commands without returning additional wire fields', asyn const backend = desktopAccountBackend('http://127.0.0.1:1234', (request) => { requests.push(request) return Promise.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null, token: 'private' }) - }, () => Promise.resolve('')) + }, '') const client = { version: '1.2.3', locale: 'en', timezoneOffsetSeconds: 28_800 } expect(await backend.start(client)).toEqual({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) expect(await backend.signOut(client)).toMatchObject({ status: 'signed-out' }) @@ -59,12 +60,13 @@ it('receives live expiry separately from account snapshots', async () => { const snapshot = { status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } } const received = Promise.withResolvers() - const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve('')) + const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), 'dsh-auth-fixture=signed') const stop = backend.watch( (value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged, ) onTestFinished(stop) - await connected + const [, upgrade] = await connected as [unknown, IncomingMessage] + expect(upgrade.headers.cookie).toBe('dsh-auth-fixture=signed') // Logical streams share the socket; observe their opening frames before sending data. const streams = new Map() const opened = Promise.withResolvers() diff --git a/apps/desktop/tests/cua-optional-package.spec.ts b/apps/desktop/tests/cua-optional-package.spec.ts index 85ef15f0ee..a6b42cdcfd 100644 --- a/apps/desktop/tests/cua-optional-package.spec.ts +++ b/apps/desktop/tests/cua-optional-package.spec.ts @@ -1,4 +1,4 @@ -/** Cold pnpm requests exercise on-demand CUA dependency/platform metadata with inert package contents. */ +/** Cold pnpm requests exercise shipped CUA dependency/platform metadata with inert package contents. */ import { createHash } from 'node:crypto' import { createServer } from 'node:http' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -37,14 +37,14 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for const cli = await readManifest('apps/cli') const bundle = await readManifest('packages/experimental/cua-bundle') const native = await readManifest('packages/experimental/cua-macos') - expect(cli.dependencies).not.toHaveProperty(bundleName) + expect(cli.dependencies).toHaveProperty(bundleName) expect(bundle.dependencies).not.toHaveProperty(providerName) expect(bundle.optionalDependencies).toEqual({ [providerName]: 'workspace:*' }) expect(native.os).toEqual(['darwin']) const version = '1.0.0' const bundleDependencies = Object.fromEntries(Object.keys(bundle.dependencies as Record).map(name => [name, version])) const manifests = [ - { name: cli.name, version }, + { name: cli.name, version, dependencies: { [bundleName]: version } }, { name: bundleName, version, dependencies: bundleDependencies, optionalDependencies: { [providerName]: version } }, ...Object.keys(bundleDependencies).map(name => ({ name, version })), { name: providerName, version, os: native.os, files: native.files }, @@ -102,13 +102,13 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) const address = server.address() if (!address || typeof address === 'string') throw new Error('fixture registry has no TCP address') - for (const target of ['darwin-base', 'darwin', 'win32', 'linux', 'linux-unrestricted']) { + for (const target of ['darwin', 'win32', 'linux', 'linux-unrestricted']) { unrestricted = target === 'linux-unrestricted' const project = join(root, target) await mkdir(project) - await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [target === 'darwin-base' ? String(cli.name) : bundleName]: version } })) + await writeFile(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { [String(cli.name)]: version } })) await writeFile(join(project, 'pnpm-workspace.yaml'), [ - 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target === 'darwin-base' ? 'darwin' : target}]`, + 'autoInstallPeers: false', 'supportedArchitectures:', ` os: [${unrestricted ? 'linux' : target}]`, ' cpu: [arm64]', 'minimumReleaseAge: 0', '', ].join('\n')) requests.length = 0 @@ -117,8 +117,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for `--config.registry=http://127.0.0.1:${address.port}/`, `--config.store-dir=${join(project, 'store')}`, `--config.cache-dir=${join(project, 'cache')}`, `--config.state-dir=${join(project, 'state')}`, ], project) - expect(requests.includes(`/${bundleName}.tgz`)).toBe(target !== 'darwin-base') - expect(requests.includes('/@deepseek-ai/dsh-cua.tgz')).toBe(target !== 'darwin-base') + expect(requests).toContain(`/${bundleName}.tgz`) + expect(requests).toContain('/@deepseek-ai/dsh-cua.tgz') expect(requests.includes(`/${providerName}.tgz`), `${target}: ${JSON.stringify(requests)}\n${output}`) .toBe(target === 'darwin' || unrestricted) } diff --git a/apps/desktop/tests/logged-notarytool-exit.spec.ts b/apps/desktop/tests/logged-notarytool-exit.spec.ts index 8deaf4ece4..82e14f232d 100644 --- a/apps/desktop/tests/logged-notarytool-exit.spec.ts +++ b/apps/desktop/tests/logged-notarytool-exit.spec.ts @@ -1,5 +1,10 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { expect, it, vi } from 'vitest' import { runLoggedNotarytool } from '../scripts/logged-notarytool.mjs' +import { createPackagingRun } from '../scripts/packaging-run.mjs' const command = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>()) vi.mock('node:child_process', async (importOriginal) => { @@ -28,6 +33,52 @@ it('does not treat failed upload output as a completed submission', async () => command.mockRejectedValueOnce(Object.assign(new Error('upload failed'), { code: 1, stdout: JSON.stringify({ id }), stderr: '', })) - await expect(runLoggedNotarytool(args)).rejects.toThrow('upload failed') + const work = runLoggedNotarytool(args) + await expect(work).rejects.toThrow('notarytool submit failed (code: 1') + await expect(work).rejects.toHaveProperty('cause.message', 'upload failed') expect(command).toHaveBeenCalledOnce() }) + +it.each([false, true])('prints the redacted Apple failure with a journal=%s and leaves stdout for protocol replies', async (recorded) => { + const root = await mkdtemp(join(tmpdir(), 'notary-cli-')) + const directory = recorded ? createPackagingRun(root, {}).directory : undefined + const savedArgv = process.argv + const savedExit = process.exitCode + const savedDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + try { + if (directory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = directory + process.argv = [process.execPath, fileURLToPath(new URL('../scripts/logged-notarytool.mjs', import.meta.url)), + 'submit', '/fixture/app.zip', '--apple-id', 'private@example.test', '--password', 'secret-sentinel', + '--team-id', 'private-team', '--wait', '--output-format', 'json'] + command.mockReset() + command.mockRejectedValueOnce(Object.assign(new Error('connection closed: private@example.test secret-sentinel private-team'), { + code: 69, signal: null, stdout: 'Apple response: secret-sentinel', stderr: 'HTTP 503 Service Unavailable', + })) + vi.resetModules() + await import('../scripts/logged-notarytool.mjs') + expect(command).toHaveBeenCalledOnce() + expect(process.exitCode).toBe(1) + expect(stdout).not.toHaveBeenCalled() + const output = stderr.mock.calls.map(([chunk]) => String(chunk)).join('') + const diagnostics = [output, ...directory === undefined ? [] : [await readFile(join(directory, 'events.jsonl'), 'utf8')]] + for (const diagnostic of diagnostics) { + expect(diagnostic).toContain('code: 69') + expect(diagnostic).toContain('Apple response: [REDACTED]') + expect(diagnostic).toContain('HTTP 503 Service Unavailable') + expect(diagnostic).toContain('connection closed') + for (const secret of ['private@example.test', 'secret-sentinel', 'private-team']) expect(diagnostic).not.toContain(secret) + } + expect(output.includes('events.jsonl')).toBe(recorded) + } finally { + process.argv = savedArgv + process.exitCode = savedExit + if (savedDirectory === undefined) delete process.env.DSH_DESKTOP_PACKAGING_RUN_DIR + else process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = savedDirectory + stderr.mockRestore() + stdout.mockRestore() + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts index 4ad63e41fd..b5ac451827 100644 --- a/apps/desktop/tests/macos-runtime.spec.ts +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -3,9 +3,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { signMacOSRuntime } from '../scripts/macos-runtime.ts' -import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' +import { signMacOSRuntimeCode, verifyMacOSNotarizedApplication, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' -vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() })) +vi.mock('../scripts/verify-macos-signature.mjs', () => ({ + signMacOSRuntimeCode: vi.fn(), verifyMacOSNotarizedApplication: vi.fn(), verifyMacOSRuntimeCode: vi.fn(), +})) const roots: string[] = [] function root(): string { const path = mkdtempSync(join(tmpdir(), 'desktop-signing-')) @@ -26,6 +28,34 @@ it('signs Mach-O files in their final locations and verifies each signature', as expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) }) +const helperBundle = 'node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/darwin-arm64/generation/DSH Computer Use.app' +function machO(path: string): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, Buffer.from('cffaedfe00000000', 'hex')) +} + +it('verifies the notarized Computer Use helper as a bundle and signs other bundles per file', async () => { + const path = root() + machO(join(path, helperBundle, 'Contents/MacOS/dsh-cua-native')) + writeFileSync(join(path, helperBundle, 'Contents/Info.plist'), '') + // LibreOffice ships an unsigned application bundle whose executables are signed in place. + const office = join(path, 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/program/LibreOfficeDev.app/Contents/MacOS/soffice') + machO(office) + machO(join(path, 'addon.node')) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(2) + expect(verifyMacOSNotarizedApplication).toHaveBeenCalledExactlyOnceWith(join(path, helperBundle), identity) + expect(vi.mocked(signMacOSRuntimeCode).mock.calls.map(([file]) => file).sort()).toEqual([join(path, 'addon.node'), office].sort()) +}) + +it('rejects a helper bundle that fails notarized-application verification before signing any file', async () => { + const path = root() + machO(join(path, helperBundle, 'Contents/MacOS/dsh-cua-native')) + machO(join(path, 'addon.node')) + vi.mocked(verifyMacOSNotarizedApplication).mockImplementationOnce(() => { throw new Error('stapler validate exited with 65') }) + await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).rejects.toThrow('stapler validate') + expect(signMacOSRuntimeCode).not.toHaveBeenCalled() +}) + // File symlinks in these macOS fixtures require privileges unavailable to ordinary Windows processes. it.skipIf(process.platform === 'win32')('signs a shared Python interpreter once and retains both executable aliases', async () => { const path = root() diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 2e6a2bd48e..861521546d 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -73,6 +73,7 @@ describe('desktop macOS release signature', () => { expect(config.asarUnpack).toEqual(expect.arrayContaining([ '**/*.{node,dylib,dll,so,exe}', '**/@vscode/ripgrep-*/bin/rg', + '**/node_modules/@deepseek-ai/dsh-experimental-cua-macos/native/bin/**/*', ])) expect(config).toMatchObject({ appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 3b1e63e00c..924808eb0d 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -22,7 +22,10 @@ type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown const fileDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise>(), install: vi.fn() })) vi.mock('../src/file-drag.ts', () => ({ installDesktopFileDrag: () => { fileDrag.install(); return fileDrag.dispose } })) -vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) +vi.mock('../src/web-document.ts', () => ({ + authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn(), + removeLegacyHostCookies: harness.removeLegacyHostCookies, +})) vi.mock('../src/operation-report-job.ts', () => ({ runOperationReportJob: vi.fn(async () => ({ appended: false, timedOut: false, cancelled: false })) })) // Report persistence has its own unit tests; here it resolves within microtasks so the fatal // dialog never outlives the test that triggered it. @@ -215,7 +218,8 @@ const harness = await vi.hoisted(async () => { failWindow(error: Error) { windowFailure = error }, windows, views, hosts, handlers, app, FakeWindow, FakeView, FakeHost, powerMonitor, nativeTheme, analytics, trays, FakeTray, backgroundNotice, shellDialog, - menu, popup, socketHeaders: vi.fn(), loginShell, readLoginShell, updateCheck, updateDownload, updateInstall, + menu, popup, socketHeaders: vi.fn(), connectWelcome: vi.fn(), removeLegacyHostCookies: vi.fn(async () => {}), hostCookies: {}, + loginShell, readLoginShell, updateCheck, updateDownload, updateInstall, platformDispose, platformCloseAndWait, @@ -306,7 +310,6 @@ vi.mock('electron', () => ({ dialog: harness.dialog, shell: { openExternal: harness.openExternal }, nativeTheme: harness.nativeTheme, - net: { fetch: vi.fn() }, ipcMain: { on: harness.ipcOn, handle: (channel: string, handler: InvokeHandler) => { @@ -317,7 +320,7 @@ vi.mock('electron', () => ({ }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, session: { defaultSession: { - on: vi.fn(), + on: vi.fn(), cookies: harness.hostCookies, setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, @@ -389,14 +392,17 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({ }, })) vi.mock('../src/welcome-backend.ts', () => ({ - connectDesktopWelcome: async () => ({ - analyticsEnabled: async () => harness.analyticsEnabled, - report: harness.analytics, - readLocalePreference: async () => null, - read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise, - save: async () => ({ ok: true }), - account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) }, - }), + connectDesktopWelcome: (...args: unknown[]) => { + harness.connectWelcome(...args) + return { + analyticsEnabled: async () => harness.analyticsEnabled, + report: harness.analytics, + readLocalePreference: async () => null, + read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise, + save: async () => ({ ok: true }), + account: { watch: harness.watchAccount, state: async () => ({ status: 'signed-out', attempt: null }) }, + } + }, })) function configureTestAuthPopup(allow: boolean): void { @@ -1126,7 +1132,7 @@ describe('desktop main startup', () => { } finally { harness.app.name = originalName } }) - it.each(['http:', 'https:'] as const)('attaches %s Host socket credentials only to the owned application origin and window', async (protocol) => { + it.each(['http:', 'https:'] as const)('passes the %s Host cookie explicitly and attaches socket credentials only to the owned application origin and window', async (protocol) => { await import('../src/main.ts') await harness.preparing.promise harness.prepared.resolve() @@ -1134,6 +1140,8 @@ describe('desktop main startup', () => { harness.hosts[0]!.url = `${protocol}//127.0.0.1:3080/?token=test` harness.hosts[0]!.ready.resolve() await Promise.resolve(invoke(DESKTOP_IPC.boot)) + expect(harness.connectWelcome).toHaveBeenCalledWith(`${protocol}//127.0.0.1:3080/?token=test`, 'test-cookie') + expect(harness.removeLegacyHostCookies).toHaveBeenCalledWith(harness.hostCookies, `${protocol}//127.0.0.1:3080/?token=test`) const handler = harness.socketHeaders.mock.calls[0]![1] as ( details: { url: string; webContentsId: number; requestHeaders: Record }, callback: (result: unknown) => void, @@ -1155,6 +1163,19 @@ describe('desktop main startup', () => { expect(callback).toHaveBeenLastCalledWith({}) }) + it('logs a legacy Host cookie cleanup failure without blocking boot', async () => { + const failure = new Error('cookie store unavailable') + harness.removeLegacyHostCookies.mockRejectedValueOnce(failure) + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.boot)) + await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith('desktop: legacy Host cookie cleanup failed', failure) }) + expect(harness.connectWelcome).toHaveBeenCalled() + }) + it('shares login key discovery with onboarding and rejects foreign renderers', async () => { await readyForUpdate() const window = harness.windows[0]! diff --git a/apps/desktop/tests/web-document.spec.ts b/apps/desktop/tests/web-document.spec.ts index 0fd9b591b1..6507712ef6 100644 --- a/apps/desktop/tests/web-document.spec.ts +++ b/apps/desktop/tests/web-document.spec.ts @@ -2,7 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { authenticateWebHost, forwardWebRequest, serveWebDocument } from '../src/web-document.ts' +import { authenticateWebHost, forwardWebRequest, removeLegacyHostCookies, serveWebDocument } from '../src/web-document.ts' const roots: string[] = [] afterEach(async () => { @@ -86,3 +86,14 @@ it('refuses another page origin without forwarding its request', async () => { expect(response.status).toBe(403) expect(fetch).not.toHaveBeenCalled() }) + +it('removes only legacy Host authentication cookies for the Host hostname', async () => { + const removed: [string, string][] = [] + const cookies = { + get: vi.fn(async () => [{ name: 'dsh-auth-old' }, { name: 'other-app' }, { name: 'dsh-auth-older' }]), + remove: vi.fn(async (url: string, name: string) => { removed.push([url, name]) }), + } + await removeLegacyHostCookies(cookies, 'http://127.0.0.1:1234/?token=secret') + expect(cookies.get).toHaveBeenCalledExactlyOnceWith({ url: 'http://127.0.0.1:1234' }) + expect(removed).toEqual([['http://127.0.0.1:1234', 'dsh-auth-old'], ['http://127.0.0.1:1234', 'dsh-auth-older']]) +}) diff --git a/apps/desktop/tests/welcome-flow.e2e.ts b/apps/desktop/tests/welcome-flow.e2e.ts index ce1dc896b2..08347912ec 100644 --- a/apps/desktop/tests/welcome-flow.e2e.ts +++ b/apps/desktop/tests/welcome-flow.e2e.ts @@ -12,6 +12,7 @@ import { DesktopProjectManager } from '../src/project-manager.ts' import { resolveDesktopPaths } from '../src/paths.ts' import { desktopClientMetadata } from '../src/client-metadata.ts' import { connectDesktopWelcome, type DesktopWelcomeBackend } from '../src/welcome-backend.ts' +import { authenticateWebHost } from '../src/web-document.ts' import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' import { prepareDevelopmentProject } from '../scripts/development-project.ts' @@ -129,17 +130,7 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => { host = new DesktopHostProcess(process.execPath, project, paths.profile) const { url } = await host.start() hostOrigin = new URL(url).origin - let cookie = '' - const send: typeof fetch = async (input, init) => { - const headers = new Headers(init?.headers) - if (cookie !== '') headers.set('cookie', cookie) - const response = await fetch(input, { ...init, headers, redirect: 'manual' }) - if (response.status !== 303) return response - cookie = response.headers.getSetCookie().map(value => value.split(';')[0]).join('; ') - await response.body?.cancel() - return fetch(new URL(response.headers.get('location')!, url), { headers: { cookie } }) - } - backend = await connectDesktopWelcome(url, send, () => Promise.resolve(cookie)) + backend = connectDesktopWelcome(url, await authenticateWebHost(url)) } const status = async () => backend.read() const fingerprint = (): string => createHash('sha256') diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts index dea2905583..d5dfb480a2 100644 --- a/apps/desktop/tests/welcome-host.spec.ts +++ b/apps/desktop/tests/welcome-host.spec.ts @@ -9,9 +9,8 @@ function transport(preference?: string) { { ns: 'llm-pi-ai', value: { profiles: { example: { apiKeyEnv: 'EXAMPLE_API_KEY' } } } }, { ns: 'locale', value: preference === undefined ? {} : { preference } }, ] - const send = vi.fn[1]>(async (_input, init) => { - if (init?.method !== 'POST') return new Response('index') - const { rpcId, method, payload } = JSON.parse(init.body as string) as { + const send = vi.fn[2]>>(async (_input, init) => { + const { rpcId, method, payload } = JSON.parse(init!.body as string) as { rpcId: string method: string payload: { args: { ref: string; value: string; refs: string[] } } @@ -29,27 +28,28 @@ function transport(preference?: string) { return { send, keys, namespaces } } -const url = 'http://127.0.0.1:19387/?token=fixture' +const url = 'http://127.0.0.1:19387/' +const cookie = 'dsh-auth-fixture=signed' describe('desktop welcome Web operations', () => { - it('authenticates through Web and stores only through the configured credential reference', async () => { + it('authenticates with the explicit Host cookie and stores only through the configured credential reference', async () => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) - expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' }) + const backend = connectDesktopWelcome(url, cookie, host.send) expect(await backend.analyticsEnabled()).toBe(true) expect(await backend.save('sk-example')).toEqual({ ok: true }) expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example') expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null }) - for (const [input, init] of host.send.mock.calls.slice(1)) { + for (const [input, init] of host.send.mock.calls) { expect(input).toMatch(/^http:\/\/127\.0\.0\.1:19387\/api\//u) - expect(init).toMatchObject({ credentials: 'include', redirect: 'error' }) + expect(init).toMatchObject({ redirect: 'error', headers: { cookie } }) + expect(init).not.toHaveProperty('credentials') } }) it('reads the explicit language preference and recognizes another provider key', async () => { const host = transport('zh') host.keys.set('EXAMPLE_API_KEY', 'sk-other') - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) expect(await backend.read()).toMatchObject({ localePreference: 'zh', hasApiKey: true }) host.keys.clear() expect(await backend.read()).toMatchObject({ hasApiKey: false }) @@ -58,7 +58,7 @@ describe('desktop welcome Web operations', () => { it('reads language without querying account or model providers', async () => { const host = transport('zh') - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) host.send.mockClear() expect(await backend.readLocalePreference()).toBe('zh') expect(host.send).toHaveBeenCalledOnce() @@ -68,7 +68,7 @@ describe('desktop welcome Web operations', () => { it('allows profiles without the official provider and retains custom key detection', async () => { const host = transport() host.namespaces.splice(0, 1) - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) expect(await backend.read()).toMatchObject({ hasApiKey: false, writable: false }) host.keys.set('EXAMPLE_API_KEY', 'custom-key') expect(await backend.read()).toMatchObject({ hasApiKey: true, writable: false }) @@ -78,7 +78,7 @@ describe('desktop welcome Web operations', () => { it.each(['', 'bad key', 'key\n'])('rejects malformed keys without contacting a provider: %s', async (value) => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) host.send.mockClear() expect(await backend.save(value)).toEqual({ ok: false }) expect(host.send).not.toHaveBeenCalled() @@ -86,28 +86,22 @@ describe('desktop welcome Web operations', () => { it('keeps provider diagnostics out of failures and rejects unmatched RPC envelopes', async () => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) host.send.mockRejectedValueOnce(new Error('private credential sk-must-not-leak')) expect(await backend.save('sk-example')).toEqual({ ok: false }) host.send.mockResolvedValueOnce(Response.json({ type: 'server-response', rpcId: 'other', result: { ok: true } })) await expect(backend.read()).rejects.toThrow('Web RPC failed') }) - it('submits native analytics through authenticated RPC with a bounded request', async () => { const host = transport() - const backend = await connectDesktopWelcome(url, host.send) + const backend = connectDesktopWelcome(url, cookie, host.send) const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} } await backend.report(event) const [input, init] = host.send.mock.calls.at(-1)! expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report') - expect(init?.credentials).toBe('include') + expect(init?.headers).toMatchObject({ cookie }) expect(init?.signal).toBeInstanceOf(AbortSignal) expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } }) }) - - it('refuses an unauthenticated Web launch', async () => { - const send = vi.fn[1]>(async () => new Response(null, { status: 401 })) - await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed') - }) }) diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts index 4ae8743e2e..e578c8a655 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -1,4 +1,6 @@ -vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() })) +vi.mock('../src/web-document.ts', () => ({ + authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn(), removeLegacyHostCookies: async () => {}, +})) /** Welcome startup uses the Host before transitioning to the workspace. */ import { afterEach, expect, it, vi } from 'vitest' @@ -84,10 +86,9 @@ vi.mock('electron', () => ({ showInactive = state.showInactiveWorkspace async loadURL(url: string) { state.contents = this.webContents; await state.loadWorkspace(url); this.ready?.() } }, - net: { fetch: vi.fn() }, nativeTheme: state.nativeTheme, session: { defaultSession: { - on: vi.fn(), + on: vi.fn(), cookies: {}, setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: vi.fn() }, } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, @@ -124,7 +125,7 @@ vi.mock('../src/host-process.ts', () => ({ }, })) vi.mock('../src/welcome-backend.ts', () => ({ - connectDesktopWelcome: async () => ({ + connectDesktopWelcome: () => ({ analyticsEnabled: async () => false, readLocalePreference: async () => state.preference, read: async () => { diff --git a/apps/web/package.json b/apps/web/package.json index 51ecc68787..b011aa916d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,7 +43,7 @@ "@deepseek-ai/dsh-client-web": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", "@deepseek-ai/dsh-pwsh-local": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index e314a707ba..068cc2c07e 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md /apps-web-browser-e2e: - en: 2deef0df72a6465c - zh: b24989ffb2d8586a + en: 88e00180b920a42e + zh: 8ddba0f4bbb4f1f6 /apps-web-browser-e2e/completion-observations: en: 8c139966983959cc zh: 54f09f9b7ac84cda diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 969cf3a965..be2467e2d1 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -16,6 +16,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m Same-day message goldens use the shared `WEB_FIXTURE_TIME`: seeded transcripts pass it as `createdAt` and pin the browser calendar with `pinBrowserClock`; scenarios that submit live messages also use `pinHostClock` and restore it after teardown. These clocks advance with real elapsed time while keeping native timers intact, so midnight does not change a scenario's date labels. +Every scenario requires the whole client composition to activate. The product boot only warns about inactive entries outside its required list, but the `watchConsole` tripwire, the [real-host smoke](smoke-real.e2e.ts), and the [assembled jsdom boot](assembled-boot.ts) treat a `web boot: warning` as a failure. No scenario option relaxes this: an inactive plugin in a product composition is a defect to fix. + ## Completion observations State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 29234f33c6..317725a2dc 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -16,6 +16,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps 当天消息的 golden 使用共享的 `WEB_FIXTURE_TIME`:预置会话将它传为 `createdAt`,并通过 `pinBrowserClock` 固定浏览器日期;提交实时消息的场景还使用 `pinHostClock`,在清理后恢复。固定后的时钟随真实耗时前进,保留原生定时器,因此跨午夜不会改变场景的日期标签。 +每个场景都要求整个客户端组合全部激活。产品启动只对必需名单外的未激活 entry 告警,但 `watchConsole` 绊线、[真实 Host 冒烟](smoke-real.e2e.ts)与[组装 jsdom 启动](assembled-boot.ts)把 `web boot: warning` 视为失败。没有任何场景选项放宽这一点:产品组合中出现未激活插件就是需要修复的缺陷。 + ## 完成状态观察 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index f97978d346..bb9b3d2065 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -14,7 +14,7 @@ import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, + ZH_BROWSER_LOCALE, assertWarningDialogLayout, connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, } from './support.ts' import { AUTO_REVIEW_FIXTURE, captureAutoReviewState } from './auto-review-fixture.ts' @@ -154,6 +154,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await page.getByRole('menuitem', { name: '完全权限' }).click() const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' }) await dialog.waitFor({ timeout: 10_000 }) + await assertWarningDialogLayout(dialog) const enable = dialog.getByRole('button', { name: '启用完全权限' }) expect(await enable.isDisabled()).toBe(true) @@ -163,7 +164,18 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() + const acknowledgement = dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }) + expect(await acknowledgement.evaluate((input) => { + const label = input.closest('label')! + const textStyle = getComputedStyle(label) + const inputStyle = getComputedStyle(input) + return { + focused: document.activeElement === input, + fontSize: textStyle.fontSize, lineHeight: textStyle.lineHeight, weight: textStyle.fontWeight, + gap: textStyle.gap, width: inputStyle.width, height: inputStyle.height, + } + })).toEqual({ focused: true, fontSize: '14px', lineHeight: '20px', weight: '400', gap: '6px', width: '14px', height: '14px' }) + await dialog.getByText('我已了解风险,并愿意继续', { exact: true }).click() expect(await enable.isEnabled()).toBe(true) await enable.click() await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 19d7924381..6da971cf03 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,79 +1,112 @@ -// Keyless assembled-browser coverage for the opt-in Agent Teams bundle +// Keyless assembled-browser coverage for per-Session agent teamwork // over the real Host projection frame flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import { readFileSync } from 'node:fs' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' -import * as yaml from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' -import type {} from '@deepseek-ai/dsh-experimental-agent-team' +import type {} from '@deepseek-ai/dsh-agent-team' +import { SessionId } from '@deepseek-ai/dsh-session' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { assertWarningDialogLayout, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, writeComposerDraft } 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 RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md') -const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) -const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) -const INSTALL_ANCHORS = [ - fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/package.json', import.meta.url)), -] const MODE = webSnapshotMode() +const RESEARCHER_DESCRIPTION = 'Researcher: investigate the construction and verify every bound before reporting the result. '.repeat(8).trim() +const RECEIPT_LOCALES = [ + { + language: 'English', code: 'en', browserLocale: 'en-US', connect: connectFreshWorkspace, + modelButton: /^Select model/u, toggle: 'Enable agent teamwork', title: 'Agent teamwork', + enabled: 'Enabled', disabled: 'Disabled', + suggestions: 'Trigger suggestions', commandLabel: 'Team', + failure: 'Failed: Use /team on or /team off.', + }, + { + language: 'Chinese', code: 'zh', browserLocale: 'zh-CN', connect: connectFreshWorkspaceZh, + modelButton: /^选择模型/u, toggle: '启用智能体团队协作', title: '智能体团队协作', + enabled: '已开启', disabled: '已停用', + suggestions: '触发候选建议', commandLabel: '智能体团队协作', + failure: '执行失败:Use /team on or /team off.', + }, +] as const -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 +type ReceiptLocale = typeof RECEIPT_LOCALES[number] + +async function seedTeamConversation(page: Page, scaffold: WebScaffold): Promise { + 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 task board.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { + stream: [], + 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 }) } -describe('Agent Teams panel overlay', () => { - it('matches the shipped Agent Teams bundle', () => { - expect(profileEntries(OVERLAY)).toEqual(profileEntries(TEAM_PATCH)) - }) -}) +async function withTeamReceiptPage( + locale: ReceiptLocale, + run: (page: Page, scaffold: WebScaffold, tripwire: ReturnType) => Promise, +): Promise { + const scaffold = await launchWebScaffold() + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ + viewport: { width: 1680, height: 1000 }, locale: locale.browserLocale, timezoneId: 'Asia/Shanghai', + }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await locale.connect(page, scaffold.workspaceCwd) + try { + await seedTeamConversation(page, scaffold) + await run(page, scaffold, tripwire) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, `web-e2e-team-command-${locale.code}`) + throw error + } + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +} -describe('web e2e: Agent Teams panel', () => { +describe('web e2e: agent teamwork panel', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, extraInstallAnchors: INSTALL_ANCHORS }) + scaffold = await launchWebScaffold() browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { 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', { - stream: [], - 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 }) + await seedTeamConversation(page, scaffold) }, 120_000) afterAll(async () => { @@ -81,35 +114,318 @@ describe('web e2e: Agent Teams panel', () => { await scaffold?.close() }) - it('displays agent-owned task changes through a read-only board without a refresh action', async () => { + it('explains agent teamwork on hover and focus without changing the Session selection', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-info')) + await page.getByRole('button', { name: /^Select model/u }).click() + const info = page.getByRole('button', { name: 'About agent teamwork', exact: true }) + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + const tooltip = page.getByRole('tooltip') + await info.hover() + await tooltip.waitFor({ state: 'visible' }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'info.expected.md'), await captureStableAria(page, '[role="tooltip"]', scaffold.workspaceCwd), MODE) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + await page.getByRole('button', { name: /^Select model/u }).hover() + await tooltip.waitFor({ state: 'hidden' }) + await page.keyboard.press('Shift') + await info.focus() + await tooltip.waitFor({ state: 'visible' }) + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + expect(await toggle.isVisible()).toBe(true) + await info.click() + await tooltip.waitFor({ state: 'visible' }) + expect(await info.getAttribute('aria-describedby')).toBe(await tooltip.getAttribute('id')) + expect(await toggle.getAttribute('aria-checked')).toBe('false') + await page.keyboard.press('Escape') + await tooltip.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }) + + it('toggles from the row label and empty space while keeping the information button independent', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-row')) + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + const info = page.getByRole('button', { name: 'About agent teamwork', exact: true }) + const label = page.getByText('Enable agent teamwork', { exact: true }) + const labelBounds = await label.boundingBox() + if (labelBounds === null) throw new Error('Team label is not visible') + const labelPoint = { x: labelBounds.x + labelBounds.width / 2, y: labelBounds.y + labelBounds.height / 2 } + await page.mouse.move(labelPoint.x, labelPoint.y) + expect(await toggle.evaluate((element) => { + const row = element.parentElement! + return getComputedStyle(row).backgroundColor !== 'rgba(0, 0, 0, 0)' + })).toBe(true) + await page.mouse.click(labelPoint.x, labelPoint.y) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await info.click() + await page.getByRole('tooltip').waitFor({ state: 'visible' }) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + const gap = await toggle.evaluate((element) => { + const control = element.getBoundingClientRect() + const help = element.parentElement!.querySelector('button:not([role="switch"])')!.getBoundingClientRect() + return { x: (help.right + control.left) / 2, y: control.top + control.height / 2 } + }) + await page.mouse.click(gap.x, gap.y) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(await page.getByRole('tooltip').count()).toBe(0) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }) + + it('defaults off and saves a Session selection in the model menu', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-mode')) + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + await toggle.waitFor() + expect(await toggle.getAttribute('aria-checked')).toBe('false') + await toggle.focus() + await page.keyboard.press('ArrowUp') + expect(await toggle.evaluate(element => element === document.activeElement)).toBe(false) + await page.keyboard.press('ArrowDown') + expect(await toggle.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Space') + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + const agent = scaffold.ctx.agents.list()[0]! + expect(scaffold.ctx.commands.list(agent).some(command => command.name === 'team')).toBe(false) + expect(agent.session.ownEvents().filter(event => event.type === 'command/run' || event.type === 'command/done')).toEqual([]) + const other = await scaffold.ctx.agents.create({ sessionId: SessionId('other-team-mode'), agentOptions: agent.options }) + expect(scaffold.ctx.agentTeams.isEnabled(other.agent)).toBe(false) + await other.dispose() + const beforeReload = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: /^Select model/u }).click() + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + acknowledgeReloadConnectionLoss(tripwire, beforeReload) + }, 60_000) + + it('toggles from the model menu and keeps text editing usable after refocusing', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-command-input')) + const input = page.locator('[data-composer-input]').first() + const agent = scaffold.ctx.agents.list()[0]! + for (const enabled of [false, true]) { + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe(String(enabled)) + await page.keyboard.press('Escape') + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(enabled) + await expect.poll(() => input.textContent()).toBe('') + await page.getByRole('button', { name: 'Account menu', exact: true }).focus() + await input.click() + await page.keyboard.insertText('asdas') + await expect.poll(() => input.textContent()).toBe('asdas') + expect(await input.evaluate((element) => { + const selection = window.getSelection() + return document.activeElement === element && selection?.isCollapsed === true + && selection.anchorNode?.textContent === 'asdas' && selection.anchorOffset === 5 + })).toBe(true) + await input.press('Backspace') + await expect.poll(() => input.textContent()).toBe('asda') + await writeComposerDraft(page, input, '') + } + expect(tripwire.pageErrors).toEqual([]) + }) + + it('shows tasks without children or a divider and hides the entry after the last task is deleted', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-task-only')) + const agent = scaffold.ctx.agents.list()[0]! + expect(await page.locator('[data-subagent-action]').count()).toBe(0) + const task = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Plan before delegation', description: 'Prepare shared work before creating subagents', + }) + await page.getByRole('button', { name: '0 subagents', exact: true }).click() + const board = page.locator('[data-team-panel]') + await board.getByText(task.subject, { exact: true }).waitFor() + const panel = page.locator('[data-subagent-panel]') + expect(await panel.getByRole('treeitem').count()).toBe(0) + await panel.getByText('No subagents', { exact: true }).waitFor() + expect(await panel.locator('[data-subagent-section-header]').textContent()).toBe('Subagents') + expect(await panel.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await board.locator('[data-team-task-details]').count()).toBe(0) + expect(await board.getByRole('button', { expanded: false }).count()).toBe(1) + expect(await board.evaluate((element) => { + const style = getComputedStyle(element) + return { border: style.borderTopWidth, margin: style.marginTop } + })).toEqual({ border: '0px', margin: '0px' }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'task-only.expected.md'), + await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd), MODE) + await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'delete' }) + await page.locator('[data-subagent-action]').waitFor({ state: 'hidden' }) + await page.locator('[data-subagent-panel]').waitFor({ state: 'hidden' }) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('opens the member tree and live read-only tasks in one panel', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') onTestFinished(() => { projectionReads.mockRestore() }) - await page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }).click() - const action = page.getByRole('dialog', { name: 'Agent team', exact: true }) - await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor() - await action.getByText('lead').waitFor() + const agent = scaffold.ctx.agents.list()[0]! + const memberId = SessionId('team-panel-researcher') + agent.session.append('subagent/catalog', { + version: 2, childId: memberId, childCreatedAt: Date.now(), mode: 'external', + label: RESEARCHER_DESCRIPTION, + }) + const countAction = page.locator('[data-subagent-action]') + await countAction.waitFor() + await page.mouse.move(2, 2) + expect(await countAction.evaluate((element) => { + const header = element.closest('[data-slot="conversation.session.header.actions"]')!.parentElement! + const preset = header.querySelector('[data-icon="dsw-icon-agent-preset-outline-regular"]')! + const users = element.querySelector('[data-icon="dsw-icon-users-outline-regular"]')! + return { + display: getComputedStyle(header).display, gap: getComputedStyle(header).gap, + presetOpacity: getComputedStyle(preset).opacity, + presetParentOpacity: getComputedStyle(preset.parentElement!).opacity, + sameColor: getComputedStyle(preset).color === getComputedStyle(users).color, + } + })).toEqual({ display: 'flex', gap: '10px', presetOpacity: '1', presetParentOpacity: '1', sameColor: true }) + const countTrigger = countAction.getByRole('button', { name: /subagents?$/u }) + await countTrigger.click() + await countTrigger.click() + await page.locator('[data-subagent-panel]').waitFor({ state: 'detached' }) + expect(await countTrigger.evaluate(element => element === document.activeElement)).toBe(true) + await countTrigger.click() + const action = page.locator('[data-subagent-panel]') + await action.getByRole('treeitem', { name: /Researcher/u }).waitFor() + expect(await action.locator('[data-team-panel], [data-subagent-section-header]').count()).toBe(0) + expect(await action.getByText('No shared tasks yet. Create them through the conversation.').count()).toBe(0) + expect(await action.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await action.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await action.getByRole('heading', { name: 'Members', exact: true }).count()).toBe(0) expect(projectionReads).not.toHaveBeenCalled() expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0) - // Host-side Team mutations reach the open panel through projection frames only. - const agent = scaffold.ctx.agents.list()[0]! + await action.getByRole('treeitem', { name: /Researcher/u }).waitFor() + expect(await page.locator('[data-subagent-panel]').count()).toBe(1) + expect(await page.getByRole('button', { name: 'Task board', exact: true }).count()).toBe(0) const task = await scaffold.ctx.agentTeams.createTask(agent, { - subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'], + subject: 'Agent task', description: 'Created by the root agent', writeScopes: ['src/web'], }) - await action.getByText('Agent task', { exact: true }).waitFor() - await action.getByText('Owner: Unowned', { exact: true }).waitFor() + const row = action.locator(`[data-team-task="${task.id}"]`) + await row.getByRole('button', { name: 'Pending Agent task', exact: true, expanded: false }).waitFor() + expect(await action.locator('[data-subagent-section-header]').textContent()).toBe('Subagents1 external') + expect(await action.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await action.locator('[data-team-panel]').evaluate((element) => { + const style = getComputedStyle(element) + return { border: style.borderTopWidth, margin: style.marginTop } + })).toEqual({ border: '0px', margin: '4px' }) + expect(await action.evaluate((element) => { + const tree = element.querySelector('[role="tree"]')! + const board = element.querySelector('[data-team-panel]')! + return Boolean(tree.compareDocumentPosition(board) & Node.DOCUMENT_POSITION_FOLLOWING) + })).toBe(true) + expect(await row.evaluate(element => ({ tag: element.tagName, parent: element.parentElement?.tagName }))) + .toEqual({ tag: 'LI', parent: 'UL' }) + expect(await action.locator('[data-team-task-details], article').count()).toBe(0) + expect(await action.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + await action.getByRole('heading', { name: 'Shared tasks 1 pending', exact: true }).waitFor() + expect(await action.evaluate(element => getComputedStyle(element).padding)).toBe('6px') + expect(await action.locator('[data-subagent-section-header]').evaluate((element) => { + const style = getComputedStyle(element) + return { padding: style.padding, gap: style.gap } + })).toEqual({ padding: '4px 7px 8px', gap: '7px' }) + expect(await action.locator('[data-team-panel]').getByRole('heading') + .evaluate(element => getComputedStyle(element).columnGap)).toBe('7px') + for (const [title, progress] of [ + [action.locator('[data-subagent-section-header]').getByRole('heading'), action.getByText('1 external', { exact: true })], + [action.locator('[data-team-panel]').getByText('Shared tasks', { exact: true }), action.getByText('1 pending', { exact: true })], + ] as const) { + expect(await progress.evaluate(element => getComputedStyle(element).marginInlineStart)).toBe('4px') + const titleRight = await title.evaluate(element => element.getBoundingClientRect().right) + const progressLeft = await progress.evaluate(element => element.getBoundingClientRect().left) + expect(Math.abs(progressLeft - titleRight - 11)).toBeLessThanOrEqual(1) + } + const member = action.getByRole('treeitem', { name: /Researcher/u }) + const memberTitle = member.getByText(RESEARCHER_DESCRIPTION, { exact: true }) + const taskTitle = row.getByText('Agent task', { exact: true }) + const titleLefts = await Promise.all([ + memberTitle, + action.locator('[data-subagent-section-header]').getByRole('heading'), + action.locator('[data-team-panel]').getByText('Shared tasks', { exact: true }), + taskTitle, + ].map(locator => locator.evaluate(element => element.getBoundingClientRect().left))) + expect(Math.max(...titleLefts) - Math.min(...titleLefts)).toBeLessThanOrEqual(1) + const primaryStyles = await Promise.all([memberTitle, taskTitle].map(locator => locator.evaluate((element) => { + const style = getComputedStyle(element) + return { fontSize: style.fontSize, color: style.color } + }))) + expect(primaryStyles.map(style => style.fontSize)).toEqual(['13px', '13px']) + expect(primaryStyles[1]).toEqual(primaryStyles[0]) + expect(await member.getByText('team-panel-researcher', { exact: true }) + .evaluate(element => getComputedStyle(element).fontSize)).toBe('12px') + const memberHover = member.locator(':scope > div') + for (const target of [member, memberHover]) { + expect(await target.evaluate((element) => { + const style = getComputedStyle(element) + return [style.paddingTop, style.paddingBottom] + })).toEqual(['4px', '4px']) + } + expect(await member.evaluate(element => getComputedStyle(element).minHeight)).toBe('40px') + expect(await member.evaluate((element) => { + const style = getComputedStyle(element) + return { left: style.paddingLeft, right: style.paddingRight, gap: style.gap } + })).toEqual({ left: '9px', right: '6px', gap: '7px' }) + expect(await memberHover.evaluate((element) => { + const style = getComputedStyle(element) + return { left: style.paddingLeft, right: style.paddingRight, gap: style.gap, margin: style.margin } + })).toEqual({ left: '6px', right: '6px', gap: '7px', margin: '-4px -6px' }) + const taskHover = row.getByRole('button') + expect(await taskHover.evaluate((element) => { + const style = getComputedStyle(element) + return [style.paddingTop, style.paddingBottom] + })).toEqual(['7px', '7px']) + expect(await taskHover.evaluate(element => element.getBoundingClientRect().height)).toBe(31) + expect(await taskHover.evaluate((element) => { + const style = getComputedStyle(element) + return { left: style.paddingLeft, right: style.paddingRight, gap: style.gap, margin: style.margin } + })).toEqual({ left: '6px', right: '6px', gap: '7px', margin: '0px -6px' }) + expect(await taskHover.evaluate(element => element.getBoundingClientRect().width + - element.parentElement!.getBoundingClientRect().width)).toBe(12) + const hoverStyles = [] + for (const target of [memberHover, taskHover]) { + await target.hover() + hoverStyles.push(await target.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const style = getComputedStyle(element) + return { left: bounds.left, right: bounds.right, radius: style.borderRadius, background: style.backgroundColor } + })) + } + expect(Math.abs(hoverStyles[0]!.left - hoverStyles[1]!.left)).toBeLessThanOrEqual(1) + expect(Math.abs(hoverStyles[0]!.right - hoverStyles[1]!.right)).toBeLessThanOrEqual(1) + expect(hoverStyles[1]!.radius).toBe(hoverStyles[0]!.radius) + expect(hoverStyles[1]!.background).toBe(hoverStyles[0]!.background) + expect(hoverStyles[1]!.background).not.toBe('rgba(0, 0, 0, 0)') + await row.getByRole('button').click() + await row.locator('[data-team-task-owner]').filter({ hasText: 'Unowned' }).waitFor() + await row.getByText('Created by the root agent', { exact: true }).waitFor() + expect(await row.locator('[data-team-task-details]').evaluate((element) => { + const style = getComputedStyle(element) + return { fontSize: style.fontSize, indent: style.paddingLeft } + })).toEqual({ fontSize: '12px', indent: '21px' }) + expect(await row.locator('[data-team-task-details] p, [data-team-task-owner]') + .evaluateAll(elements => elements.every(element => getComputedStyle(element).fontSize === '12px'))).toBe(true) + expect(await row.getByRole('button').getAttribute('aria-expanded')).toBe('true') + expect(await action.evaluate(element => element.getBoundingClientRect().width)).toBe(360) await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'claim', }) - await action.getByText('In progress', { exact: true }).waitFor() - await action.getByText('Owner: lead', { exact: true }).waitFor() + await row.getByRole('img', { name: 'In progress', exact: true }).waitFor() + await row.locator('[data-team-task-owner]').filter({ hasText: agent.id }).waitFor() + await action.getByRole('heading', { name: 'Shared tasks 1 in progress', exact: true }).waitFor() expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) expect(await action.locator('input, select, textarea').count()).toBe(0) - const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) expect(projectionReads).not.toHaveBeenCalled() expect(tripwire.pageErrors).toEqual([]) @@ -117,15 +433,113 @@ describe('web e2e: Agent Teams panel', () => { await page.keyboard.press('Escape') }, 60_000) + it('hides the board while disabled and restores its tasks when re-enabled', async () => { + await page.getByRole('button', { name: /^Select model/u }).click() + const toggle = page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + expect(await page.locator('[data-subagent-action]').count()).toBe(1) + await page.keyboard.press('Escape') + await page.locator('[data-subagent-action]').getByRole('button', { name: '1 subagent', exact: true }).click() + await page.getByRole('treeitem', { name: /Researcher/u }).waitFor() + expect(await page.locator('[data-team-panel]').count()).toBe(0) + expect(await page.locator('[data-subagent-panel]').evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await page.locator('[data-subagent-section-header]').count()).toBe(0) + await page.keyboard.press('Escape') + await page.getByRole('button', { name: /^Select model/u }).click() + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.keyboard.press('Escape') + await page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }).click() + await page.locator('[data-subagent-panel]').getByText('Agent task', { exact: true }).waitFor() + await page.keyboard.press('Escape') + }) + + it('keeps the narrow Users trigger focused and opens its catalog only on explicit activation', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-narrow-trigger')) + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: '1 subagent', exact: true }) + const panel = page.locator('[data-subagent-panel]') + const count = trigger.locator('[data-subagent-count]') + const viewport = page.viewportSize()! + expect(await count.isVisible()).toBe(true) + expect(await trigger.locator('[data-icon="dsw-icon-users-outline-regular"]').count()).toBe(1) + expect(await trigger.locator('[data-icon="dsw-icon-users-outline-regular"]').evaluate((element) => { + const bounds = element.getBoundingClientRect() + return { width: bounds.width, height: bounds.height } + })).toEqual({ width: 14, height: 14 }) + expect(await trigger.locator('[data-icon="dsw-icon-chevron-down-outline-regular"]').count()).toBe(0) + expect(await trigger.locator('svg').count()).toBe(1) + await trigger.focus() + const original = await trigger.elementHandle() + if (original === null) throw new Error('Subagent trigger is missing') + try { + await page.setViewportSize({ width: 390, height: 700 }) + await expect.poll(() => count.evaluate(element => getComputedStyle(element).display)).toBe('none') + expect(await original.evaluate(element => element.isConnected && element === document.activeElement)).toBe(true) + const expandSidebar = page.locator('[data-sidebar-right-expand]') + const compactStyles = await Promise.all([trigger, expandSidebar].map(control => control.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const icon = element.querySelector('svg')!.getBoundingClientRect() + const style = getComputedStyle(element) + return { width: bounds.width, height: bounds.height, padding: style.padding, radius: style.borderRadius, + color: style.color, iconWidth: icon.width, iconHeight: icon.height } + }))) + expect(compactStyles[0]).toEqual(compactStyles[1]) + expect(compactStyles[0]).toMatchObject({ width: 28, height: 28, padding: '0px', iconWidth: 15, iconHeight: 15 }) + expect(await trigger.evaluate(element => getComputedStyle(element.closest('[data-slot="conversation.session.header.actions"]')!.parentElement!).gap)).toBe('8px') + await expandSidebar.hover() + const expandHover = await expandSidebar.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + }) + await page.mouse.move(1, 1) + await page.keyboard.press('Tab') + const countTooltip = page.getByRole('tooltip', { name: '1 subagent', exact: true }) + await countTooltip.waitFor({ state: 'hidden' }) + await trigger.hover() + expect(await trigger.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + })).toEqual(expandHover) + expect(expandHover.background).not.toBe('rgba(0, 0, 0, 0)') + // Observe beyond the wide trigger's 150ms hover-open deadline. + await page.waitForTimeout(200) + expect(await panel.count()).toBe(0) + await countTooltip.waitFor({ state: 'visible' }) + expect(await panel.count()).toBe(0) + await trigger.click() + await panel.waitFor() + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + expect(await page.getByRole('tooltip', { name: '1 subagent', exact: true }).count()).toBe(0) + await panel.getByRole('treeitem', { name: /Researcher/u }).waitFor() + await panel.locator('[data-team-task]').getByRole('button', { expanded: false }).waitFor() + await trigger.click() + await panel.waitFor({ state: 'detached' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + await trigger.press('ArrowDown') + await panel.waitFor() + await page.mouse.move(1, 1) + // Keyboard activation stays pinned beyond the 120ms pointer-leave grace. + await page.waitForTimeout(180) + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + await original.dispose() + } + }) + it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard')) - const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) + const panel = page.locator('[data-subagent-panel]') + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }) const viewport = page.viewportSize()! await trigger.click() - await panel.getByText('lead', { exact: true }).waitFor() + await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor() try { - for (const size of [{ width: 760, height: 540 }, { width: 600, height: 420 }]) { + for (const size of [{ width: 760, height: 540 }, { width: 600, height: 420 }, { width: 390, height: 700 }]) { await page.setViewportSize(size) await expect.poll(() => panel.evaluate((element) => { const rect = element.getBoundingClientRect() @@ -136,6 +550,30 @@ describe('web e2e: Agent Teams panel', () => { .every(([x, y]) => element.contains(document.elementFromPoint(x!, y!))) })).toBe(true) } + const row = panel.getByRole('treeitem', { name: /Researcher/u }) + const primary = row.getByText(RESEARCHER_DESCRIPTION, { exact: true }) + const secondary = row.getByText('team-panel-researcher', { exact: true }) + expect(await row.getByRole('button').count()).toBe(0) + expect(await primary.evaluate((element) => { + const style = getComputedStyle(element) + return { + tag: element.tagName, clipped: element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight, + whiteSpace: style.whiteSpace, overflowWrap: style.overflowWrap, + } + })).toEqual({ tag: 'SPAN', clipped: false, whiteSpace: 'normal', overflowWrap: 'anywhere' }) + const primaryBounds = await primary.boundingBox() + const secondaryBounds = await secondary.boundingBox() + expect(primaryBounds).not.toBeNull() + expect(secondaryBounds).not.toBeNull() + expect(primaryBounds!.y + primaryBounds!.height).toBeLessThanOrEqual(secondaryBounds!.y) + const beforeClick = page.url() + await primary.click() + expect(page.url()).toBe(beforeClick) + expect(await page.getByRole('tooltip', { name: RESEARCHER_DESCRIPTION, exact: true }).count()).toBe(0) + expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + expect(await secondary.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await secondary.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) await panel.getByRole('heading', { name: /Shared tasks/u }).click() expect(await panel.count()).toBe(1) await page.mouse.click(2, 2) @@ -166,8 +604,8 @@ describe('web e2e: Agent Teams panel', () => { await saveFailureShot(page, 'web-e2e-agent-team-hover') console.error('Agent Team hover diagnostics', JSON.stringify({ errors: tripwire.pageErrors, warnings: tripwire.warnings })) }) - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }) + const panel = page.locator('[data-subagent-panel]') await trigger.hover() await panel.waitFor() await panel.hover() @@ -178,37 +616,412 @@ describe('web e2e: Agent Teams panel', () => { await page.keyboard.press('Escape') }) - it('offers expansion when resizing makes a task description exceed two lines', async () => { + it('keeps compact tasks ordered, opens one full detail at a time, and scrolls only the fixed-width menu', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-compact-details')) const agent = scaffold.ctx.agents.list()[0]! - await scaffold.ctx.agentTeams.createTask(agent, { - subject: 'Responsive task', - description: 'Check the task description before you start work. '.repeat(3), - writeScopes: [], + const completed = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Completed preparation', description: 'Preparation is complete.', writeScopes: [], + }) + const claimed = await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: completed.id, expectedRevision: completed.revision, action: 'claim', + }) + await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: completed.id, expectedRevision: claimed.revision, action: 'complete', + }) + const blocker = await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Dependency task', description: 'Finish this prerequisite first.', writeScopes: [], + }) + const subject = 'Responsive task with a long title that remains fully readable when expanded at the minimum supported viewport' + const description = 'Check every dependency and write scope before you start work.\n'.repeat(16).trim() + const task = await scaffold.ctx.agentTeams.createTask(agent, { + subject, description, blockedBy: [blocker.id], writeScopes: ['src/web/components'], }) const viewport = page.viewportSize()! - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) - const card = panel.locator('article').filter({ hasText: 'Responsive task' }) + const trigger = page.locator('[data-subagent-action]').getByRole('button', { name: /subagents?$/u }) + const panel = page.locator('[data-subagent-panel]') + const board = panel.locator('[data-team-panel]') + const row = board.locator(`[data-team-task="${task.id}"]`) + const emptyRow = board.locator(`[data-team-task="${completed.id}"]`) + onTestFinished(() => page.setViewportSize(viewport)) + // Constrain the menu so closed and expanded task rows both exercise its scrollbar. + await page.setViewportSize({ width: viewport.width, height: 540 }) await trigger.click() - await card.waitFor() - expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + await row.waitFor() + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + expect(await board.locator('[data-team-task-details], article').count()).toBe(0) + expect(await board.getByRole('button', { expanded: false }).count()).toBe(4) + expect(await board.locator('[data-team-task]').evaluateAll(elements => elements.map(element => element.getAttribute('data-status')))) + .toEqual(['in_progress', 'completed', 'pending', 'pending']) + const counts = board.getByText('1 completed\u2002·\u20021 in progress\u2002·\u20022 pending', { exact: true }) + await counts.waitFor() + expect(await counts.textContent()).toBe('1 completed\u2002·\u20021 in progress\u2002·\u20022 pending') + expect(await panel.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await panel.getByRole('button', { name: /^Show (more|less)$/u }).count()).toBe(0) + await row.getByRole('img', { name: 'Blocked by dependencies', exact: true }).waitFor() + const measureScroll = async () => panel.evaluate((element) => { + const style = getComputedStyle(element) + const bounds = element.getBoundingClientRect() + const scroller = element.querySelector('[role="tree"]')!.parentElement! + const scrollStyle = getComputedStyle(scroller) + const scrollBounds = scroller.getBoundingClientRect() + const taskBounds = element.querySelector('[data-team-task] button')!.getBoundingClientRect() + const scrollbarWidth = Number.parseFloat(getComputedStyle(scroller, '::-webkit-scrollbar').width) + return { + innerWidth: bounds.width - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight) + - Number.parseFloat(style.borderLeftWidth) - Number.parseFloat(style.borderRightWidth), + clientWidth: scroller.clientWidth, clientHeight: scroller.clientHeight, scrollHeight: scroller.scrollHeight, + overflowing: scroller.scrollHeight > scroller.clientHeight, + gutter: scrollStyle.scrollbarGutter, marginRight: scrollStyle.marginRight, + trackMargin: scrollStyle.getPropertyValue('--dsh-scrollbar-track-margin').trim(), + taskLeft: taskBounds.left, taskRight: taskBounds.right, + trackLeft: scrollBounds.right - scrollbarWidth, trackRight: scrollBounds.right, panelRight: bounds.right, + arrowsBeforeTrack: [...element.querySelectorAll('[data-team-task] button svg')] + .every(arrow => arrow.getBoundingClientRect().right <= scrollBounds.right - scrollbarWidth), + } + }) + const closedScroll = await measureScroll() + expect(closedScroll.overflowing, JSON.stringify(closedScroll)).toBe(true) + expect(Math.abs(closedScroll.clientWidth - closedScroll.innerWidth), JSON.stringify(closedScroll)).toBeLessThanOrEqual(1) + expect(closedScroll.gutter).toBe('stable') + expect(closedScroll.marginRight).toBe('-5px') + expect(closedScroll.trackMargin).toBe('6px') + await row.getByRole('button').click() + const expandedScroll = await measureScroll() + expect(expandedScroll.overflowing).toBe(true) + expect(expandedScroll.clientWidth).toBe(closedScroll.clientWidth) + expect(expandedScroll.taskLeft).toBe(closedScroll.taskLeft) + expect(expandedScroll.taskRight).toBe(closedScroll.taskRight) + expect(expandedScroll.arrowsBeforeTrack).toBe(true) + expect(expandedScroll.trackLeft).toBeGreaterThanOrEqual(expandedScroll.panelRight - 6) + expect(expandedScroll.trackRight).toBeLessThanOrEqual(expandedScroll.panelRight - 1) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + const details = row.locator('[data-team-task-details]') + expect(await details.locator('p:not([data-team-task-owner])').textContent()).toBe(description) + expect(await details.locator('[data-team-task-owner]').textContent()).toBe('Owner: Unowned') + expect(await details.locator(':scope > *').evaluateAll(elements => elements.map(element => + element.hasAttribute('data-team-task-owner') ? 'owner' : element.tagName))).toEqual(['P', 'owner']) + expect(await details.locator('dl, dt, dd').count()).toBe(0) + for (const omitted of [task.id, blocker.subject, 'Readiness', 'Write scopes', 'Write scope warnings', 'src/web/components']) { + expect(await details.textContent()).not.toContain(omitted) + } + expect(await row.getByRole('button').getAttribute('aria-controls')).toBe(await details.getAttribute('id')) try { - await page.setViewportSize({ width: 360, height: 700 }) - await card.getByRole('button', { name: 'Show more' }).waitFor() - expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true) - await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE) - await card.getByRole('button', { name: 'Show more' }).click() - expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true) - await card.getByRole('button', { name: 'Show less' }).click() + await page.setViewportSize({ width: 390, height: 700 }) + await expect.poll(() => panel.evaluate((element) => { + const rect = element.getBoundingClientRect() + return rect.width === Math.min(360, innerWidth - 32) && rect.left >= 16 && rect.right <= innerWidth - 16 + && rect.top >= 16 && rect.bottom <= innerHeight - 16 + && element.scrollWidth <= element.clientWidth + })).toBe(true) + expect(await panel.locator('*').evaluateAll((elements) => { + const scrollers = elements.filter(element => + ['auto', 'scroll'].includes(getComputedStyle(element).overflowY) + && element.scrollHeight > element.clientHeight) + return scrollers.map(element => ({ + hasHeader: element.querySelector('[data-subagent-section-header]') !== null, + hasTree: element.querySelector('[role="tree"]') !== null, + hasBoard: element.querySelector('[data-team-panel]') !== null, + fitsWidth: element.scrollWidth <= element.clientWidth, + })) + })).toEqual([{ hasHeader: true, hasTree: true, hasBoard: true, fitsWidth: true }]) + const narrowScroll = await measureScroll() + expect(Math.abs(narrowScroll.clientWidth - narrowScroll.innerWidth), JSON.stringify(narrowScroll)).toBeLessThanOrEqual(1) + expect(narrowScroll.arrowsBeforeTrack).toBe(true) + const scroller = panel.getByRole('tree').locator('..') + await scroller.hover({ position: { x: 20, y: 20 } }) + await Promise.all([ + scroller.evaluate(element => new Promise((resolve) => { + element.addEventListener('scrollend', () => { resolve() }, { once: true }) + })), + page.mouse.wheel(0, 180), + ]) + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + await scroller.evaluate((element) => { element.scrollTo({ top: 0 }) }) + await expect.poll(() => scroller.evaluate(element => element.scrollTop)).toBe(0) + expect(await row.getByText(subject, { exact: true }).evaluate(element => + element.scrollWidth <= element.clientWidth && getComputedStyle(element).whiteSpace === 'pre-wrap')).toBe(true) + expect(await details.locator('p:not([data-team-task-owner])').evaluate(element => element.scrollHeight === element.clientHeight)).toBe(true) + const ownerBounds = await details.locator('[data-team-task-owner]').boundingBox() + const descriptionBounds = await details.locator('p:not([data-team-task-owner])').boundingBox() + expect(ownerBounds).not.toBeNull() + expect(descriptionBounds).not.toBeNull() + expect(ownerBounds!.height).toBeLessThanOrEqual(18) + expect(ownerBounds!.y).toBeGreaterThanOrEqual(descriptionBounds!.y + descriptionBounds!.height) + const detailColor = await details.evaluate(element => getComputedStyle(element).color) + expect(await details.locator('[data-team-task-owner], [data-team-task-owner] span') + .evaluateAll(elements => elements.map(element => getComputedStyle(element).color))) + .toEqual([detailColor, detailColor, detailColor]) + expect(await counts.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const titleBounds = await board.getByText('Shared tasks', { exact: true }).boundingBox() + const countBounds = await counts.boundingBox() + expect(titleBounds).not.toBeNull() + expect(countBounds).not.toBeNull() + expect(countBounds!.y).toBeGreaterThanOrEqual(titleBounds!.y + titleBounds!.height) + await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd), MODE) + await emptyRow.getByRole('button').click() + expect(await row.getAttribute('data-expanded')).toBe('false') + expect(await row.locator('[data-team-task-details]').count()).toBe(0) + expect(await board.locator('[data-team-task-details]').count()).toBe(1) + expect(await emptyRow.locator('[data-team-task-owner]').textContent()).toBe(`Owner: ${agent.id}`) + expect(await emptyRow.locator('[data-team-task-details] p:not([data-team-task-owner])').textContent()).toBe(completed.description) + expect(await emptyRow.locator('dl, dt, dd').count()).toBe(0) + expect(await emptyRow.locator('[data-team-task-details]').textContent()).not.toContain(completed.id) + await emptyRow.getByRole('button').click() + expect(await board.locator('[data-team-task-details]').count()).toBe(0) await page.setViewportSize(viewport) - await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) } finally { await page.keyboard.press('Escape') await page.setViewportSize(viewport) } }) + it('confirms disabling after a recorded model request and keeps cancellation side-effect free', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-team-disable-confirmation')) + const agent = scaffold.ctx.agents.list()[0]! + const selection = agent.model.current() + if (selection === undefined) throw new Error('Team fixture has no selected model') + agent.session.append('request/header', { header: { config: selection }, reason: 'initial' }) + await scaffold.ctx.sessions.flush(agent.session) + const openConfirmation = async (): Promise => { + await page.getByRole('button', { name: /^Select model/u }).click() + await page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }).click() + await page.getByRole('dialog', { name: 'Disable agent teamwork?', exact: true }).waitFor() + } + await openConfirmation() + const dialog = page.getByRole('dialog', { name: 'Disable agent teamwork?', exact: true }) + await assertWarningDialogLayout(dialog) + expect(await page.getByRole('menu', { name: 'Model and reasoning effort', exact: true }).count()).toBe(0) + expect(await dialog.getByRole('checkbox').count()).toBe(0) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'disable-confirmation.expected.md'), + await captureStableAria(page, '[aria-modal="true"]', scaffold.workspaceCwd), MODE) + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('button', { name: /^Select model/u }).evaluate(element => element === document.activeElement)).toBe(true) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + await openConfirmation() + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('button', { name: /^Select model/u }).evaluate(element => element === document.activeElement)).toBe(true) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + await openConfirmation() + await dialog.getByRole('button', { name: 'Disable', exact: true }).click() + await dialog.waitFor({ state: 'hidden' }) + await expect.poll(() => page.getByRole('button', { name: /^Select model/u }).evaluate(element => element === document.activeElement)).toBe(true) + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + await page.getByRole('button', { name: /^Select model/u }).click() + await page.getByRole('switch', { name: 'Enable agent teamwork', exact: true }).click() + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + expect(await dialog.count()).toBe(0) + await page.keyboard.press('Escape') + expect(agent.session.ownEvents().filter(event => event.type === 'command/run' || event.type === 'command/done')).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) + + it('removes both section headings and the board divider when the last shared task is deleted', async () => { + const agent = scaffold.ctx.agents.list()[0]! + await page.getByRole('button', { name: '1 subagent', exact: true }).click() + const panel = page.locator('[data-subagent-panel]') + await panel.locator('[data-team-panel]').waitFor() + for (const task of scaffold.ctx.agentTeams.listTasks(agent).toReversed()) { + if (task.status === 'deleted') continue + await scaffold.ctx.agentTeams.updateTask(agent, { + taskId: task.id, expectedRevision: task.revision, action: 'delete', + }) + } + await panel.locator('[data-team-panel]').waitFor({ state: 'detached' }) + expect(await panel.locator('[data-subagent-section-header]').count()).toBe(0) + expect(await panel.getByRole('heading').count()).toBe(0) + expect(await panel.getByText('No shared tasks yet. Create them through the conversation.').count()).toBe(0) + expect(await panel.getByRole('treeitem', { name: /Researcher/u }).count()).toBe(1) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + await page.keyboard.press('Escape') + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'task.expected.md', 'resize.expected.md', 'info.expected.md', 'task-only.expected.md', + 'disable-confirmation.expected.md', 'collaboration.zh.expected.md', + 'command-error.en.expected.md', 'command-error.zh.expected.md', + ]) }) }) + +it.each(RECEIPT_LOCALES)('toggles teamwork in $language without command records or Chat changes after reload', async (locale) => { + await withTeamReceiptPage(locale, async (page, scaffold, tripwire) => { + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Team receipt fixture has no Agent') + const chat = page.locator('[data-chat-flow]').first() + const recordedReady = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) + expect(recordedReady).toContain('Ready.') + expect(recordedReady).toContain('Open the task board.') + const captureLayout = () => chat.evaluate((element) => { + const flow = element.getBoundingClientRect() + return { + height: flow.height, + rows: [...element.querySelectorAll('[data-chat-flow-key]')].map((row) => { + const bounds = row.getBoundingClientRect() + return { kind: row.getAttribute('data-chat-flow-kind'), top: bounds.top - flow.top, height: bounds.height } + }), + } + }) + const recordedLayout = await captureLayout() + const commandEvents = () => agent.session.snapshotEvents() + .filter(event => event.type === 'command/run' || event.type === 'command/done') + expect(commandEvents()).toEqual([]) + const input = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, input, '/') + const suggestions = page.getByRole('listbox', { name: locale.suggestions, exact: true }) + await suggestions.getByRole('option').first().waitFor() + const candidate = suggestions.getByRole('option').filter({ hasText: locale.commandLabel }) + expect(await candidate.count()).toBe(0) + await writeComposerDraft(page, input, '/tea') + await expect.poll(() => suggestions.getByRole('option').count()).toBe(0) + expect(await candidate.count()).toBe(0) + await input.press('Escape') + await suggestions.waitFor({ state: 'hidden' }) + await writeComposerDraft(page, input, '') + expect(commandEvents()).toEqual([]) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + const assertChatUnchanged = async (): Promise => { + await chat.getByText('Ready.', { exact: true }).waitFor() + expect(await page.locator('[data-team-command], [data-chat-flow-kind="command"], [data-command-input]').count()).toBe(0) + expect(await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)).toBe(recordedReady) + await expect.poll(captureLayout).toEqual(recordedLayout) + } + for (const enabled of [true, false]) { + await page.getByRole('button', { name: locale.modelButton }).click() + const toggle = page.getByRole('switch', { name: locale.toggle, exact: true }) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe(String(enabled)) + await expect.poll(() => scaffold.ctx.agentTeams.isEnabled(agent)).toBe(enabled) + expect(scaffold.ctx.commands.list(agent).some(command => command.name === 'team')).toBe(false) + expect(commandEvents()).toEqual([]) + await page.keyboard.press('Escape') + await assertChatUnchanged() + } + await scaffold.ctx.sessions.flush(agent.session) + const recordedCommands = commandEvents() + expect(recordedCommands).toEqual([]) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await assertChatUnchanged() + expect(commandEvents()).toEqual(recordedCommands) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + // Historical receipts are read-only fixtures, not invocations of a registered command. + for (const args of [' on', ' off']) { + const commandId = CommandId(`historical-team-${args.trim()}`) + agent.session.append('command/run', { commandId, name: 'team', args, source: { kind: 'user' } }) + agent.session.append('command/done', { commandId, kind: 'success', text: 'Team mode saved.' }) + } + agent.session.append('command/run', { + commandId: CommandId('historical-team-pending'), name: 'team', args: ' on', source: { kind: 'user' }, + }) + await scaffold.ctx.sessions.flush(agent.session) + const historicalCommands = commandEvents() + expect(historicalCommands.map(event => event.type)).toEqual([ + 'command/run', 'command/done', 'command/run', 'command/done', 'command/run', + ]) + const historicalWarningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await assertChatUnchanged() + expect(commandEvents()).toEqual(historicalCommands) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + acknowledgeReloadConnectionLoss(tripwire, historicalWarningStart) + }) +}) + +it.each(RECEIPT_LOCALES)('renders historical Team failures in $language without a success receipt after reload', async (locale) => { + await withTeamReceiptPage(locale, async (page, scaffold, tripwire) => { + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Team failure fixture has no Agent') + const commandId = CommandId('historical-team-error') + agent.session.append('command/run', { commandId, name: 'team', args: ' invalid', source: { kind: 'user' } }) + agent.session.append('command/done', { commandId, kind: 'error', text: 'Use /team on or /team off.' }) + await scaffold.ctx.sessions.flush(agent.session) + const row = page.locator('[data-team-command]') + const captureFailure = async (): Promise => { + await row.getByText(locale.failure, { exact: true }).waitFor() + expect(await row.count()).toBe(1) + expect(await row.getAttribute('data-state')).toBe('error') + expect(await row.getByText(locale.title, { exact: true }).count()).toBe(1) + expect(await row.getByText(locale.enabled, { exact: true }).count()).toBe(0) + expect(await row.getByText(locale.disabled, { exact: true }).count()).toBe(0) + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(false) + const collapsed = await captureStableAria(page, '[data-team-command]', scaffold.workspaceCwd) + const disclosure = row.getByRole('button', { expanded: false }) + await disclosure.click() + await row.getByRole('button', { expanded: true }).waitFor() + await row.getByText('Use /team on or /team off.', { exact: true }).waitFor() + const expanded = await captureStableAria(page, '[data-team-command]', scaffold.workspaceCwd) + return `## Failed receipt\n\n${collapsed}\n\n## Raw error\n\n${expanded}` + } + const failure = await captureFailure() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, `command-error.${locale.code}.expected.md`), failure, MODE) + await scaffold.ctx.sessions.flush(agent.session) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + expect(await captureFailure()).toBe(failure) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + }) +}) + +it('shows the Chinese empty subagent section above compact shared tasks', async () => { + await withTeamReceiptPage(RECEIPT_LOCALES[1], async (page, scaffold) => { + const agent = scaffold.ctx.agents.list()[0]! + await scaffold.ctx.agentTeams.setEnabled(agent, true) + await scaffold.ctx.agentTeams.createTask(agent, { + subject: '准备协作', description: '先准备共享任务,再创建子智能体。', + }) + await page.getByRole('button', { name: '0 个子智能体', exact: true }).click() + const panel = page.locator('[data-subagent-panel]') + await panel.getByText('暂无子智能体', { exact: true }).waitFor() + expect(await panel.locator('[data-subagent-section-header]').textContent()).toBe('子智能体') + expect(await panel.locator('[data-subagent-section-header] button, h3 button').count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + await panel.getByRole('heading', { name: '共享任务 1 待处理', exact: true }).waitFor() + expect(await panel.locator('[data-team-task-details]').count()).toBe(0) + }) +}) + +it('uses the Chinese collaboration name in the switch and disable confirmation', async () => { + const scaffold = await launchWebScaffold() + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Chinese Team fixture has no Agent') + await scaffold.ctx.agentTeams.setEnabled(agent, true) + const selection = agent.model.current() + if (selection === undefined) throw new Error('Chinese Team fixture has no model') + agent.session.append('request/header', { header: { config: selection }, reason: 'initial' }) + await scaffold.ctx.sessions.flush(agent.session) + await page.getByRole('button', { name: /^选择模型/u }).click() + const toggle = page.getByRole('switch', { name: '启用智能体团队协作', exact: true }) + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await page.getByRole('button', { name: '关于智能体团队协作', exact: true }).waitFor() + expect(await toggle.evaluate(element => element.parentElement!.scrollWidth <= element.parentElement!.clientWidth)).toBe(true) + const menu = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd) + await toggle.click() + const dialog = page.getByRole('dialog', { name: '确认停用智能体团队协作?', exact: true }) + await dialog.waitFor() + await dialog.getByText('对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。', { exact: true }).waitFor() + await assertWarningDialogLayout(dialog) + const confirmation = await captureStableAria(page, '[aria-modal="true"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'collaboration.zh.expected.md'), + `## Menu\n\n${menu}\n\n## Confirmation\n\n${confirmation}`, MODE) + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(scaffold.ctx.agentTeams.isEnabled(agent)).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml deleted file mode 100644 index bdcf49e256..0000000000 --- a/apps/web/tests/agent-team-panel.overlay.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Equivalent to the Agent Teams bundle over the stable base and Web bundles. -- id: tool-subagent-control - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - -- insert: - - id: agent-team - name: '@deepseek-ai/dsh-experimental-agent-team' - config: - maxMembers: 8 - maxTasks: 256 - maxMessageBytes: 65536 - disposalTimeoutMs: 5000 - - - id: tool-agent-team - name: '@deepseek-ai/dsh-experimental-tool-agent-team' - config: - freshProvider: spawn - forkProvider: fork - - - id: ui-agent-team - name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 42f006222d..88dc7e9f94 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -196,13 +196,16 @@ class EventSourceStub { const win = window as FixtureWindow let unmount: (() => Promise) | undefined let mountedRemote: RemoteMock | undefined +/** Boot audit warnings of the current test; the product boot tolerates inactive optional entries, this lane does not. */ +let bootWarnings: string[] = [] /** * Register the per-test jsdom setup and teardown the assembled boot needs: * English pinned before boot so role/text locators stay deterministic across * localized component migrations (the newEnglishPage e2e convention), the * observers, font events, and frame callbacks jsdom lacks, and a full reset of the document, - * the boot globals, and the injected plugin styles afterwards. + * the boot globals, and the injected plugin styles afterwards. A boot audit warning about an + * inactive entry fails the test at teardown: every assembled composition must activate completely. */ export function installAssembledBootEnv(): void { // jsdom implements no scroll geometry: the trigger menu reveals its @@ -219,7 +222,15 @@ export function installAssembledBootEnv(): void { }) } let fontsDescriptor: PropertyDescriptor | undefined + let restoreWarn: (() => void) | undefined beforeEach(() => { + bootWarnings = [] + const warn = console.warn.bind(console) + const spy = vi.spyOn(console, 'warn').mockImplementation((message?: unknown, ...rest: unknown[]) => { + if (typeof message === 'string' && message.startsWith('web boot: warning')) bootWarnings.push(message) + warn(message, ...rest) + }) + restoreWarn = () => { spy.mockRestore() } fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts') Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() }) localStorage.clear() @@ -267,6 +278,9 @@ export function installAssembledBootEnv(): void { vi.unstubAllGlobals() if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts') else Object.defineProperty(document, 'fonts', fontsDescriptor) + restoreWarn?.() + restoreWarn = undefined + if (bootWarnings.length > 0) failures.push(new Error(`assembled boot: every entry must activate\n${bootWarnings.join('\n')}`)) if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed') }) } diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 4d60b8749e..08a226a835 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -158,7 +158,6 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'agentPresets/list': structuredClone(fixture.agentPresets), 'commands/list': structuredClone(fixture.commands), 'settings/openSettingsDocument': ok({ opened: true }), - 'subagents/list': ok({ entries: [], parentAvailable: true }), 'terminal/list': ok([]), 'skills/list': ok({ skills: [] }), 'session/canOpenWorkspacePath': ok(true), diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index fab978232b..899cda61c4 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { let phase = 'running' onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`)) // Polling for zero would pass at t=0 before delivery and prove nothing. - const trigger = page.getByRole('button', { name: '1 background job running' }) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) expect(await trigger.count()).toBe(0) const started = await executeToolFixture(scaffold.ctx, { @@ -101,10 +101,28 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const jobId = JobId(matched[0]) await trigger.waitFor({ timeout: 15_000 }) - await trigger.click() - const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first() + expect(await trigger.locator('[data-state="ongoing"]').count()).toBe(1) + expect(await trigger.locator('[data-icon="dsw-icon-chevron-down-outline-regular"]').count()).toBe(0) + await trigger.hover() + const list = page.getByRole('list', { name: 'Background jobs' }) + const row = list.getByRole('listitem').first() await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) + expect(await trigger.getAttribute('aria-expanded')).toBe('true') + await row.hover() + await row.getByRole('button', { name: /Show live output/ }).focus() + await page.mouse.move(2, 2) + // Focusing a menu control pins it beyond the 120ms hover-leave deadline. + await page.waitForTimeout(180) + expect(await list.count()).toBe(1) + await trigger.click() + await list.waitFor({ state: 'detached' }) + await trigger.click() + await list.waitFor() + await page.mouse.move(2, 2) + await page.waitForTimeout(180) + expect(await list.count()).toBe(1) + expect(await trigger.getAttribute('aria-label')).toBe('1 background job') const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' }) await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) @@ -120,9 +138,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await expect.poll(() => stop.getAttribute('data-kill-state')).toBe('armed') await stop.click() - // Exact: the running trigger's name ('1 background job running') contains this label. - const idle = page.getByRole('button', { name: '1 background job', exact: true }) - await idle.waitFor({ timeout: 20_000 }) + expect(await trigger.getAttribute('aria-label')).toBe('1 background job') // The unclaimed report's reason lands in the settled row's detail. await expect.poll( () => page.getByRole('list', { name: 'Background jobs' }).textContent(), @@ -185,6 +201,82 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { expect(await trigger.evaluate(node => node === document.activeElement)).toBe(true) }, 60_000) + it('keeps the compact Layers trigger focused and opens jobs only on click or keyboard', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-compact-trigger')) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) + const list = page.getByRole('list', { name: 'Background jobs' }) + const count = trigger.locator('[data-job-count]') + const viewport = page.viewportSize()! + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + await expect.poll(() => page.locator('[data-rightbar-col]') + .evaluate(column => column.parentElement?.hasAttribute('data-animating'))).toBe(false) + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor() + await page.mouse.move(2, 2) + expect(await count.isVisible()).toBe(true) + expect(await trigger.locator('[data-icon="dsw-icon-layers-outline-regular"]').count()).toBe(1) + expect(await trigger.locator('svg').count()).toBe(1) + expect(await trigger.locator('svg').evaluate((element) => { + const bounds = element.getBoundingClientRect() + return [bounds.width, bounds.height] + })).toEqual([14, 14]) + await trigger.focus() + const original = await trigger.elementHandle() + if (original === null) throw new Error('Background jobs trigger is missing') + try { + await page.setViewportSize({ width: 390, height: 700 }) + await expect.poll(() => count.evaluate(element => getComputedStyle(element).display)).toBe('none') + expect(await original.evaluate(element => element.isConnected && element === document.activeElement)).toBe(true) + const expandSidebar = page.locator('[data-sidebar-right-expand]') + const compactStyles = await Promise.all([trigger, expandSidebar].map(control => control.evaluate((element) => { + const style = getComputedStyle(element) + const bounds = element.getBoundingClientRect() + const icon = element.querySelector('svg')!.getBoundingClientRect() + return { width: bounds.width, height: bounds.height, padding: style.padding, radius: style.borderRadius, + color: style.color, iconWidth: icon.width, iconHeight: icon.height } + }))) + expect(compactStyles[0]).toEqual(compactStyles[1]) + expect(compactStyles[0]).toMatchObject({ width: 28, height: 28, padding: '0px', iconWidth: 15, iconHeight: 15 }) + expect(await trigger.evaluate(element => getComputedStyle(element.closest('[data-slot="conversation.session.header.actions"]')!.parentElement!).gap)).toBe('8px') + await expandSidebar.hover() + const expandHover = await expandSidebar.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + }) + await expect.poll(() => trigger.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return element.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2)) + })).toBe(true) + await trigger.hover() + expect(await trigger.evaluate((element) => { + const style = getComputedStyle(element) + return { background: style.backgroundColor, color: style.color } + })).toEqual(expandHover) + expect(expandHover.background).not.toBe('rgba(0, 0, 0, 0)') + // Compact hover stays closed beyond the wide trigger's 150ms open deadline. + await page.waitForTimeout(200) + expect(await list.count()).toBe(0) + await trigger.click() + await list.waitFor() + await list.getByRole('button', { name: 'Finished 1', exact: true }).waitFor() + await trigger.click() + await list.waitFor({ state: 'detached' }) + await trigger.press('ArrowDown') + await list.waitFor() + expect(await list.getByRole('button').first().evaluate(element => element === document.activeElement)).toBe(true) + await page.mouse.move(2, 2) + // Keyboard activation remains pinned after leaving both trigger and menu. + await page.waitForTimeout(180) + expect(await list.count()).toBe(1) + await page.mouse.click(2, 2) + await list.waitFor({ state: 'detached' }) + expect(await trigger.getAttribute('aria-expanded')).toBe('false') + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + await original.dispose() + } + }) + it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) }) diff --git a/apps/web/tests/bundle-platform.e2e.ts b/apps/web/tests/bundle-platform.e2e.ts index 55d22be94d..2f3c673a3c 100644 --- a/apps/web/tests/bundle-platform.e2e.ts +++ b/apps/web/tests/bundle-platform.e2e.ts @@ -44,7 +44,7 @@ it('disables unsupported bundles, shows the Host restriction, and refuses direct const names = [name] await page.locator('[data-plugin-panel]').getByRole('button', { name: '更多', exact: true }).click() const cua = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') - expect(await cua.count()).toBe(0) + expect(await cua.count()).toBe(process.platform === 'darwin' ? 1 : 0) if (process.platform !== 'darwin') { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) .toMatchObject({ listedByDefault: false, enabled: false, error: { code: 'unsupported-platform' } }) diff --git a/apps/web/tests/credential-editor.e2e.ts b/apps/web/tests/credential-editor.e2e.ts new file mode 100644 index 0000000000..b9ee283aa4 --- /dev/null +++ b/apps/web/tests/credential-editor.e2e.ts @@ -0,0 +1,235 @@ +/** Saved credentials and endpoints reach the assembled editor and provider requests. */ +import { once } from 'node:events' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { BlockAssembler } from '@deepseek-ai/dsh-llm' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode } from './scaffold.ts' +import { openSettings, ZH_BROWSER_LOCALE } from './support.ts' + +it.each([false, true])('edits saved DeepSeek keys and restores environment detection (environment present: %s)', async (hasEnvironment) => { + const scaffold = await launchWebScaffold({ + deepSeekMissingCredential: true, + extraOverlayPath: fileURLToPath(new URL('./fixtures/credential-editor.patch.yml', import.meta.url)), + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ + source: 'process', values: hasEnvironment ? { DEEPSEEK_API_KEY: 'fixture-environment-key' } : {}, + }]), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE }) + const ref = credentialRef('DEEPSEEK_API_KEY') + await scaffold.ctx.credentials.set(ref, 'fixture-saved-deepseek-key') + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + const edit = dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }) + await edit.click() + const key = dialog.getByLabel('API 密钥', { exact: true }) + await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key') + expect(await key.getAttribute('type')).toBe('password') + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/credential-editor/saved-key.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), + ) + await dialog.getByRole('button', { name: '显示密钥', exact: true }).click() + expect(await key.getAttribute('type')).toBe('text') + await key.fill('fixture-cancelled-key') + await dialog.getByRole('button', { name: '取消', exact: true }).click() + expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-saved-deepseek-key') + await edit.click() + await expect.poll(() => key.inputValue()).toBe('fixture-saved-deepseek-key') + expect(await key.getAttribute('type')).toBe('password') + await key.fill('fixture-replacement-key') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() + expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key') + await page.reload({ waitUntil: 'load' }) + await openSettings(page, 'zh') + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await edit.click() + await expect.poll(() => key.inputValue()).toBe('fixture-replacement-key') + await key.fill('') + expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') + await dialog.getByText('环境凭据状态尚未确认', { exact: true }).waitFor() + expect(await scaffold.ctx.credentials.readStored(ref)).toBe('fixture-replacement-key') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor() + expect(await scaffold.ctx.credentials.readStored(ref)).toBeUndefined() + // A missing credential opens the first-run settings card automatically; + // an environment credential keeps an ordinary row whose editor is closed. + if (hasEnvironment) await edit.click() + await expect.poll(() => key.inputValue()).toBe('') + expect(await key.getAttribute('placeholder')).toBe('留空使用环境认证(DEEPSEEK_API_KEY)') + const hint = hasEnvironment + ? '已检测到环境变量(DEEPSEEK_API_KEY)' + : '未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。' + const detection = dialog.getByText(hint, { exact: true }) + await detection.waitFor() + const colors = await detection.evaluate((element, present) => { + const label = element as HTMLElement + const actual = getComputedStyle(label).color + const previous = label.style.color + label.style.color = present ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-label)' + const expected = getComputedStyle(label).color + label.style.color = previous + return { actual, expected } + }, hasEnvironment) + expect(colors.actual).toBe(colors.expected) + expect(await dialog.getByRole('button', { name: '显示密钥', exact: true }).count()).toBe(0) + expect((await scaffold.ctx.credentials.resolve(ref))?.source).toBe(hasEnvironment ? 'env' : undefined) + await compareOrRefreshGolden( + fileURLToPath(new URL(`./expected/credential-editor/${hasEnvironment ? 'detected' : 'missing'}-environment.expected.md`, import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), + ) + } finally { await browser.close() } + } finally { await scaffold.close() } +}) + +/** Local provider endpoint recording the destination path and authentication actually received. */ +async function connectionProbe(messages: boolean) { + const calls: { path: string; key: string | undefined }[] = [] + const server = createServer((request, response) => { + request.on('end', () => { + const key = messages ? request.headers['x-api-key'] : request.headers.authorization + calls.push({ path: request.url ?? '', key: typeof key === 'string' ? key : undefined }) + const events = messages ? [ + { type: 'message_start', message: { id: 'probe', type: 'message', role: 'assistant', model: 'credential-editor-model', content: [], usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'connection-ok' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] : [ + { choices: [{ delta: { role: 'assistant', content: 'connection-ok' }, index: 0, finish_reason: null }] }, + { choices: [{ delta: {}, index: 0, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1 } }, + ] + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(events.map(event => `${'type' in event ? `event: ${event.type}\n` : ''}data: ${JSON.stringify(event)}\n\n`).join('') + + (messages ? '' : 'data: [DONE]\n\n')) + }) + request.resume() + }) + onTestFinished(async () => { + await new Promise((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('connection probe did not bind TCP') + return { url: `http://127.0.0.1:${String(address.port)}`, calls } +} + +it.each([ + { provider: 'deepseek-official', ref: 'DEEPSEEK_API_KEY', messages: true }, + { provider: 'openai', ref: 'OPENAI_API_KEY', messages: false }, + { provider: 'anthropic', ref: 'ANTHROPIC_API_KEY', messages: true }, + { provider: 'credential-fixture', ref: 'CREDENTIAL_FIXTURE_API_KEY', messages: false }, +])('uses the saved endpoint and key on the wire for $provider, then restores fallbacks', async ({ provider, ref, messages }) => { + const ambient = await connectionProbe(messages) + const saved = await connectionProbe(messages) + const root = await mkdtemp(join(tmpdir(), 'dsh-credential-routing-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const overlay = join(root, 'connection.patch.yml') + const models = [{ id: 'credential-editor-model' }] + const baseSuffix = provider === 'anthropic' ? '' : '/v1' + const operation = provider === 'anthropic' ? '/v1/messages?beta=true' : messages ? '/messages' : '/chat/completions' + const ambientURL = `${ambient.url}/ambient${baseSuffix}` + await writeFile(overlay, JSON.stringify([ + { id: 'llm-deepseek', config: { models } }, + ...provider === 'deepseek-official' ? [] : [{ + id: 'llm-pi-ai', config: { providers: { [provider]: { + api: messages ? 'anthropic-messages' : 'openai-completions', + baseURL: ambientURL, models, + ...provider === 'credential-fixture' ? {} : { apiKeyEnv: ref }, + } } }, + }], + ])) + const scaffold = await launchWebScaffold({ + deepSeekMissingCredential: true, + extraOverlayPath: overlay, + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ + source: 'process', values: { + [ref]: 'fixture-environment-key', + DEEPSEEK_BASE_URL: ambientURL, + OPENAI_BASE_URL: ambientURL, + ANTHROPIC_BASE_URL: ambientURL, + }, + }]), + }) + try { + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + const edit = dialog.getByRole('button', { + name: `编辑 ${provider === 'deepseek-official' ? 'DeepSeek (deepseek-official)' : provider}`, exact: true, + }) + const key = dialog.getByLabel('API 密钥', { exact: true }) + const endpoint = dialog.getByLabel('API 地址', { exact: true }) + const savedURL = `${saved.url}/configured${baseSuffix}` + const generate = async (): Promise => { + const assembler = new BlockAssembler() + for await (const chunk of scaffold.ctx.llm.stream({ provider, model: 'credential-editor-model', messages: [] })) assembler.push(chunk) + expect(assembler.message({ provider, model: 'credential-editor-model' }).content).toContainEqual({ type: 'text', text: 'connection-ok' }) + } + await edit.click() + await key.fill('fixture-saved-key') + await dialog.getByText('自定义设置', { exact: true }).click() + await endpoint.fill(savedURL) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => key.count()).toBe(0) + await generate() + expect(saved.calls).toEqual([{ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }]) + expect(ambient.calls).toEqual([]) + await page.reload({ waitUntil: 'load' }) + await openSettings(page, 'zh') + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await edit.click() + expect(await key.inputValue()).toBe('fixture-saved-key') + expect(await key.getAttribute('type')).toBe('password') + await dialog.getByText('自定义设置', { exact: true }).click() + expect(await endpoint.inputValue()).toBe(savedURL) + await generate() + expect(saved.calls).toHaveLength(2) + expect(saved.calls[1]).toEqual(saved.calls[0]) + expect(ambient.calls).toEqual([]) + await key.fill('') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => key.count()).toBe(0) + await generate() + expect(saved.calls).toHaveLength(3) + expect(saved.calls[2]).toEqual({ path: `/configured${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' }) + expect(ambient.calls).toEqual([]) + await edit.click() + await key.fill('fixture-saved-key') + await dialog.getByText('自定义设置', { exact: true }).click() + await endpoint.fill('') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => key.count()).toBe(0) + await generate() + expect(saved.calls).toHaveLength(3) + expect(ambient.calls).toEqual([{ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-saved-key' : 'Bearer fixture-saved-key' }]) + await edit.click() + await key.fill('') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => key.count()).toBe(0) + await generate() + expect(saved.calls).toHaveLength(3) + expect(ambient.calls[1]).toEqual({ path: `/ambient${baseSuffix}${operation}`, key: messages ? 'fixture-environment-key' : 'Bearer fixture-environment-key' }) + expect(ambient.calls).toHaveLength(2) + expect(await scaffold.ctx.credentials.readStored(credentialRef(ref))).toBeUndefined() + } finally { await browser.close() } + } finally { await scaffold.close() } +}) diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index c5900519c6..77ec920da5 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -53,22 +53,12 @@ it.skipIf(process.platform !== 'darwin').each([ await plugins.getByRole('button', { name: '更多', exact: true }).waitFor() expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) await plugins.getByRole('button', { name: '更多', exact: true }).click() + // The installation ships the bundle off; the Experimental plugins list offers it without an installation step. const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') - expect(await card.count()).toBe(0) - expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) - .toMatchObject({ listedByDefault: false, enabled: false, installed: false }) - await plugins.getByRole('button', { name: '返回插件', exact: true }).click() - const offerCua = async () => { - await plugins.getByRole('button', { name: '添加插件', exact: true }).click() - const dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) - await dialog.getByRole('textbox', { name: '包名或地址', exact: true }).fill('@deepseek-ai/dsh-cua-bundle') - await dialog.getByRole('button', { name: '安装', exact: true }).click() - const ready = page.getByRole('dialog', { name: '已安装', exact: true }) - await ready.waitFor() - expect(await ready.getByText('无需下载,启用即可使用', { exact: true }).count()).toBe(0) - return ready.getByRole('button', { name: '立即启用', exact: true }) - } - const enableNow = await offerCua() + await plugins.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').waitFor() + const listed = (await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle') + expect(listed).toMatchObject({ availability: 'installation', optional: true, enabled: false, installed: false }) + expect(listed).not.toHaveProperty('listedByDefault') let toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) const originalAgent = scaffold.ctx.agents.list()[0]! const ptcAgent = (await scaffold.ctx.agents.create({ @@ -97,12 +87,10 @@ it.skipIf(process.platform !== 'darwin').each([ return applying }) try { - await enableNow.click() - await expect.poll(() => enableNow.isEnabled()).toBe(false) - expect(await enableNow.getAttribute('aria-busy')).toBe('true') - expect(await card.count()).toBe(0) + await toggle.click() + await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)), - await captureStableAria(page, '[data-install-phase="done"] button[aria-busy]', scaffold.workspaceCwd), webSnapshotMode()) + await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"] [role="status"]', scaffold.workspaceCwd), webSnapshotMode()) } finally { enabling.resolve(undefined) enableCall.mockRestore() @@ -145,7 +133,7 @@ it.skipIf(process.platform !== 'darwin').each([ expect(scaffold.ctx.tools.schemas(originalAgent).some(tool => tool.name === 'cua')).toBe(preset === 'ptc') } const child = await scaffold.ctx.agents.create({ - sessionId: SessionId('cua-child'), parentAgent: ptcAgent, + sessionId: SessionId('cua-child'), setup: (ctx) => { scaffold.ctx.agentPresets.composeFrom(ctx, ptcAgent.ctx) }, }) expect(scaffold.ctx.tools.schemas(child.agent).some(tool => tool.name === 'cua')).toBe(true) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 68925fc144..d4c872ac7b 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -42,7 +42,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr }) resources.scaffold = scaffold expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) - .toMatchObject({ listedByDefault: false, enabled: true, installed: true }) + .toMatchObject({ availability: 'installation', enabled: true, installed: true }) const browser = await chromium.launch() resources.browser = browser const page = await newEnglishPage(browser), tripwire = watchConsole(page) diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts index 03a22e3c41..afd89b3c77 100644 --- a/apps/web/tests/default-model.e2e.ts +++ b/apps/web/tests/default-model.e2e.ts @@ -170,7 +170,8 @@ describe('web e2e: the composer model switch is the default for later sessions', expect(popupBounds!.width).toBeCloseTo(commandMenuBounds!.width) expect(popupBounds!.x).toBeCloseTo(commandMenuBounds!.x) expect(await search.getAttribute('placeholder')).toBe('搜索模型…') - await page.getByRole('option').first().waitFor() + // Command menu height changes where the pointer lands in the model popup. + await page.getByRole('option', { name: 'Acme Large', exact: true }).hover() await compareOrRefreshGolden( fileURLToPath(new URL('./expected/default-model/command-picker.expected.md', import.meta.url)), await captureStableAria(page, '[aria-label="/model 选项"]', scaffold.workspaceCwd), diff --git a/apps/web/tests/default-product-isolation.e2e.ts b/apps/web/tests/default-product-isolation.e2e.ts index 4b535448e1..603a35753b 100644 --- a/apps/web/tests/default-product-isolation.e2e.ts +++ b/apps/web/tests/default-product-isolation.e2e.ts @@ -94,7 +94,7 @@ it('activates the actual default Client registry without experimental packages', expect(roster.plugins.some(plugin => plugin.modules.includes('@deepseek-ai/dsh-client-ui-layout'))).toBe(true) expect(experimentalClientReferences(roster)).toEqual([]) const contaminatedHost = await request('mount-experimental-entry') - const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' + const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-voice-input' expect(contaminatedHost.client.entries.map(entry => entry.id)).toContain(experimentalName) await page.reload() await expect.poll(async () => { diff --git a/apps/web/tests/desktop-onboarding.e2e.ts b/apps/web/tests/desktop-onboarding.e2e.ts index 24c3ada01e..417edf8bba 100644 --- a/apps/web/tests/desktop-onboarding.e2e.ts +++ b/apps/web/tests/desktop-onboarding.e2e.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { connectDesktopWelcome } from '../../desktop/src/welcome-backend.ts' +import { authenticateWebHost } from '../../desktop/src/web-document.ts' import { chromium, type Browser, type Page } from 'playwright' import { afterEach, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' @@ -107,7 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => async function desktopPage(locale = ZH_BROWSER_LOCALE): Promise { const opened = await browser.newPage({ viewport: { width: 1280, height: 840 }, locale, reducedMotion: 'reduce' }) - const welcome = await connectDesktopWelcome(scaffold.authenticatedUrl, (input, init) => scaffold.hostFetch(input, init)) + const welcome = connectDesktopWelcome(scaffold.baseUrl, await authenticateWebHost(scaffold.authenticatedUrl)) await opened.exposeFunction('__onboardingHasApiKey', async () => (await welcome.read()).hasApiKey) await opened.addInitScript(() => { Object.defineProperty(globalThis, 'dshOnboarding', { value: { diff --git a/apps/web/tests/expected/credential-editor/detected-environment.expected.md b/apps/web/tests/expected/credential-editor/detected-environment.expected.md new file mode 100644 index 0000000000..829eeedd37 --- /dev/null +++ b/apps/web/tests/expected/credential-editor/detected-environment.expected.md @@ -0,0 +1,25 @@ +- dialog "设置": + - text: 设置 + - navigation: + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - img "API 密钥已配置" + - button "编辑 DeepSeek (deepseek-official)": 编辑 + - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" + - textbox "API 密钥": + - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) + - paragraph: 已检测到环境变量(DEEPSEEK_API_KEY) + - group: 自定义设置 + - button "取消" + - button "保存" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/credential-editor/missing-environment.expected.md b/apps/web/tests/expected/credential-editor/missing-environment.expected.md new file mode 100644 index 0000000000..609ac50d0e --- /dev/null +++ b/apps/web/tests/expected/credential-editor/missing-environment.expected.md @@ -0,0 +1,23 @@ +- dialog "设置": + - text: 设置 + - navigation: + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - status: 已保存 DeepSeek (deepseek-official)。 + - list: + - listitem: + - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" + - textbox "API 密钥": + - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) + - paragraph: 未检测到环境变量。请填写 API 密钥,或设置环境变量 DEEPSEEK_API_KEY。 + - group: 自定义设置 + - button "取消" + - button "保存" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/credential-editor/saved-key.expected.md b/apps/web/tests/expected/credential-editor/saved-key.expected.md new file mode 100644 index 0000000000..2d4dde4639 --- /dev/null +++ b/apps/web/tests/expected/credential-editor/saved-key.expected.md @@ -0,0 +1,26 @@ +- dialog "设置": + - text: 设置 + - navigation: + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "打开配置文件" + - button "关闭" + - heading "模型" [level=2] + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 + - list: + - listitem: + - text: DeepSeek + - img "API 密钥已配置" + - button "编辑 DeepSeek (deepseek-official)": 编辑 + - text: DeepSeek deepseek-official API 密钥 + - button "认证说明": "?" + - textbox "API 密钥": + - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) + - text: fixture-saved-deepseek-key + - button "显示密钥" + - group: 自定义设置 + - button "取消" + - button "保存" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/cua-permissions/enabling.expected.md b/apps/web/tests/expected/cua-permissions/enabling.expected.md index b9d6557e3e..44fc57e3e3 100644 --- a/apps/web/tests/expected/cua-permissions/enabling.expected.md +++ b/apps/web/tests/expected/cua-permissions/enabling.expected.md @@ -1 +1 @@ -- button "立即启用" [disabled] +- status: 正在应用 Computer Use 的更改 diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 08a0750772..d4fa1c31fb 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -17,7 +17,9 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用已保存凭据 + - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) + - text: sk-messages-onboarding + - button "显示密钥" - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 03900f109b..018ea8a13c 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -17,7 +17,9 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用已保存凭据 + - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) + - text: dsh_onboarding_fixture_key + - button "显示密钥" - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 27ba737236..a5811b5d66 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -17,7 +17,9 @@ - text: DeepSeek deepseek-official API 密钥 - button "认证说明": "?" - textbox "API 密钥": - - /placeholder: 留空使用已保存凭据 + - /placeholder: 留空使用环境认证(DEEPSEEK_API_KEY) + - text: dsh_onboarding_fixture_key + - button "显示密钥" - group: - text: 自定义设置 API 地址 - textbox "API 地址": diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 70fbddd1f1..f80f40866c 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -22,11 +22,6 @@ - heading "实验性插件" [level=3] - button "更多" - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - - status - - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 diff --git a/apps/web/tests/expected/plugin-manager/icons.expected.md b/apps/web/tests/expected/plugin-manager/icons.expected.md index 4510accc6c..f42be11885 100644 --- a/apps/web/tests/expected/plugin-manager/icons.expected.md +++ b/apps/web/tests/expected/plugin-manager/icons.expected.md @@ -1,6 +1,6 @@ Third-party bundle card: image, 36×36, decoded -Disabled Agent Teams card: image, 36×36, decoded -Agent Teams detail: image, 36×36, decoded +Disabled Voice Input card: image, 36×36, decoded +Voice Input detail: image, 36×36, decoded Third-party bundle detail: image, 36×36, decoded Independent search row: image, 30×30, decoded Independent review row: generic artwork diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 5f30f09e75..fe2de50fcc 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -22,11 +22,6 @@ - heading "实验性插件" [level=3] - button "更多" - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - - status - - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 69c0998762..f564b673e8 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -22,11 +22,6 @@ - heading "实验性插件" [level=3] - button "更多" - list: - - listitem: - - button "查看 智能体团队": 智能体团队 - - text: 实验性 让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。 - - status - - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 85d666fd3e..75dda78845 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/expected/sidebar-subagent-activity/compact-running.expected.md b/apps/web/tests/expected/sidebar-subagent-activity/compact-running.expected.md new file mode 100644 index 0000000000..7a26f1bae7 --- /dev/null +++ b/apps/web/tests/expected/sidebar-subagent-activity/compact-running.expected.md @@ -0,0 +1,10 @@ +- banner: + - navigation "Session hierarchy": Delegate a background job. + - button "1 subagent" + - button "1 background job" + - button "More actions" + - button "Open right sidebar" + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - button "More conversation views" diff --git a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md index 0e4f8f018b..b60114ef0f 100644 --- a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md +++ b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md @@ -1,13 +1,13 @@ # Before opening the child - tree "Subagent sessions": - - treeitem "historical researcher Migration required · not running" [level=1]: - - text: historical researcher Migration required · not running + - treeitem "historical researcher migration-catalog-child · Migration required · not running" [level=1]: + - text: historical researcher migration-catalog-child Migration required - button "Open historical researcher in sidebar" # After opening the child - tree "Subagent sessions": - - treeitem "historical editor Migration required · not running" [level=1]: - - text: historical editor Migration required · not running + - treeitem "historical editor migration-catalog-grandchild · Migration required · not running" [level=1]: + - text: historical editor migration-catalog-grandchild Migration required - button "Open historical editor in sidebar" diff --git a/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md b/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md new file mode 100644 index 0000000000..76df6f6c57 --- /dev/null +++ b/apps/web/tests/expected/subagent-conversation/navigation-only.expected.md @@ -0,0 +1,8 @@ +- dialog "Subagent sessions": + - tree "Subagent sessions": + - treeitem "event-sourcing researcher {{uuid}} · continuable · not running 15.7K tok · {{duration}}" [level=1]: + - button "Expand event-sourcing researcher descendants" + - text: event-sourcing researcher {{uuid}} 15.7K tok {{duration}} + - treeitem "event-sourcing reviewer recorded-one-shot · one-shot · completed 0 tok · {{duration}}" [level=1]: + - text: event-sourcing reviewer recorded-one-shot 0 tok ~6mo 12d + - button "Open event-sourcing reviewer in sidebar" diff --git a/apps/web/tests/expected/subagent-result-notice/en.expected.md b/apps/web/tests/expected/subagent-result-notice/en.expected.md new file mode 100644 index 0000000000..a44d176b78 --- /dev/null +++ b/apps/web/tests/expected/subagent-result-notice/en.expected.md @@ -0,0 +1,41 @@ +- button "Subagent run completed {{clock}}" [expanded]: + - text: Subagent run completed + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. + +- button "Subagent run stopped {{clock}}" [expanded]: + - text: Subagent run stopped + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child was stopped before it finished.Its closing message:## Research result The original child reply stays available. + +- button "Subagent run failed {{clock}}" [expanded]: + - text: Subagent run failed + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child failed before it finished.Its closing message:## Research result The original child reply stays available. + +- button "Subagent reached the token limit {{clock}}" [expanded]: + - text: Subagent reached the token limit + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child ran out of room before it finished.Its closing message:## Research result The original child reply stays available. + +- button "Subagent declined the task {{clock}}" [expanded]: + - text: Subagent declined the task + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child declined the task.Its closing message:## Research result The original child reply stays available. + +- button "Subagent run completed {{clock}}" [expanded]: + - text: Subagent run completed + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.It left no closing message. + +- button "Subtask status updated {{clock}}" [expanded]: + - text: Subtask status updated + - time: {{clock}} +- paragraph: This notification triggered this response. +- text: Background subagent finished. diff --git a/apps/web/tests/expected/subagent-result-notice/zh.expected.md b/apps/web/tests/expected/subagent-result-notice/zh.expected.md new file mode 100644 index 0000000000..d26df1f590 --- /dev/null +++ b/apps/web/tests/expected/subagent-result-notice/zh.expected.md @@ -0,0 +1,41 @@ +- button "子智能体已完成任务 {{clock}}" [expanded]: + - text: 子智能体已完成任务 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.Its closing message:## Research result The original child reply stays available. + +- button "子智能体任务已停止 {{clock}}" [expanded]: + - text: 子智能体任务已停止 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child was stopped before it finished.Its closing message:## Research result The original child reply stays available. + +- button "子智能体执行失败 {{clock}}" [expanded]: + - text: 子智能体执行失败 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child failed before it finished.Its closing message:## Research result The original child reply stays available. + +- button "子智能体已达 token 上限 {{clock}}" [expanded]: + - text: 子智能体已达 token 上限 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child ran out of room before it finished.Its closing message:## Research result The original child reply stays available. + +- button "子智能体拒绝执行 {{clock}}" [expanded]: + - text: 子智能体拒绝执行 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child declined the task.Its closing message:## Research result The original child reply stays available. + +- button "子智能体已完成任务 {{clock}}" [expanded]: + - text: 子智能体已完成任务 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent notice-result-child finished and will do no further work unless you send it more.It left no closing message. + +- button "子任务状态更新 {{clock}}" [expanded]: + - text: 子任务状态更新 + - time: {{clock}} +- paragraph: 这条通知触发了本轮回复。 +- text: Background subagent finished. diff --git a/apps/web/tests/fixtures/credential-editor.patch.yml b/apps/web/tests/fixtures/credential-editor.patch.yml new file mode 100644 index 0000000000..911f60db22 --- /dev/null +++ b/apps/web/tests/fixtures/credential-editor.patch.yml @@ -0,0 +1,5 @@ +- id: llm-deepseek + config: + baseURL: http://127.0.0.1 + models: + - id: credential-editor-model diff --git a/apps/web/tests/fold-blank-matrix.e2e.ts b/apps/web/tests/fold-blank-matrix.e2e.ts index c46a62c711..920534b6ca 100644 --- a/apps/web/tests/fold-blank-matrix.e2e.ts +++ b/apps/web/tests/fold-blank-matrix.e2e.ts @@ -623,8 +623,10 @@ async function prepareWorld(browser: Browser, scenario: Case, scaffold: WebScaff const trigger = page.getByRole('button', { name: '1 subagent', exact: true }) await trigger.waitFor({ timeout: 15_000 }) await trigger.hover() - await page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) - .getByRole('button', { name: `Open ${CHILD_LABEL} in sidebar`, exact: true }).click() + const openAside = page.getByRole('treeitem', { name: new RegExp(CHILD_LABEL) }) + .getByRole('button', { name: /^Open .+ in sidebar$/u }) + await expect.poll(() => openAside.count()).toBe(1) + await openAside.click() await page.locator(SIDE_ROOT).getByText('FOLDMX_BODY', { exact: false }).last().waitFor({ timeout: 30_000 }) await page.mouse.move(0, 0) return { diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index 7388c9f4b3..ca2f53e227 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -28,7 +28,7 @@ const COMMAND = `printf 'jobs-menu-%s\\n' output; for _ in $(seq 1 3000); do [ - function placement(page: Page) { return page.evaluate(() => { const list = document.querySelector('ul[aria-label="Background jobs"]') - const anchor = document.querySelector('button[aria-label="1 background job running"]') + const anchor = document.querySelector('button[aria-label="1 background job"]') const frame = document.querySelector('[data-sidebar-browser-frame]') const dock = document.querySelector('[data-sidebar-right-panel][data-sidebar-right-open]') if (list === null || anchor === null || frame === null || dock === null) { @@ -105,6 +105,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { it('keeps the list and live output above the Browser dock while anchored and dismissable', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-jobs-menu-layering')) + const initialViewport = page.viewportSize()! const session = page.locator(`[data-row-key="session:${SEED_ID}"]`) await expect.poll(async () => { if (await session.isVisible()) return true @@ -148,7 +149,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { await page.frameLocator('[data-sidebar-browser-frame]') .getByRole('heading', { name: 'Jobs menu browser fixture' }).waitFor() - const trigger = page.getByRole('button', { name: '1 background job running', exact: true }) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) await trigger.waitFor({ timeout: 15_000 }) await trigger.focus() await page.keyboard.press('Enter') @@ -184,6 +185,13 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { await list.waitFor({ state: 'detached' }) const outsideDismisses = await trigger.getAttribute('aria-expanded') === 'false' expect(outsideDismisses).toBe(true) + await page.setViewportSize(initialViewport) + await trigger.hover() + await list.waitFor() + expect(await address.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await list.waitFor({ state: 'detached' }) + expect(await address.evaluate(element => element === document.activeElement)).toBe(true) expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 4bffdb8a62..b51d55ba99 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -85,7 +85,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => { let phase = 'streaming' onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`)) - const trigger = page.getByRole('button', { name: '1 background job running' }) + const trigger = page.getByRole('button', { name: '1 background job', exact: true }) expect(await trigger.count()).toBe(0) const started = await executeToolFixture(scaffold.ctx, { @@ -99,6 +99,14 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) const jobId = JobId(matched[0]) + await expect.poll(() => { + const job = scaffold.ctx.jobs.get(jobId, agent.id) + const output = scaffold.ctx.jobs.readAt(jobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + return job.status !== 'running' || output.includes('streamed-marker-line') + }, { timeout: 15_000 }).toBe(true) + const readyJob = scaffold.ctx.jobs.get(jobId, agent.id) + const readyOutput = scaffold.ctx.jobs.readAt(jobId, 0, agent.id).chunks.map(chunk => chunk.text).join('') + expect(readyJob.status, `${readyJob.detail ?? ''}\n${readyOutput}`).toBe('running') // The job row reaches the header over the job roster stream. await trigger.waitFor({ timeout: 15_000 }) @@ -116,13 +124,24 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) + await trigger.click() + await page.getByRole('list', { name: 'Background jobs' }).waitFor({ state: 'detached' }) + expect(await page.getByText('streamed-marker-line', { exact: true }).count()).toBe(0) + await page.mouse.move(2, 2) + await trigger.hover() + await page.getByRole('list', { name: 'Background jobs' }).waitFor() + await page.getByRole('button', { name: /Hide live output/ }).waitFor() + await page.getByText('streamed-marker-line', { exact: true }).waitFor() + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('running') + phase = 'settled' expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested') - // Settlement arrives on the observation stream itself, so the open panel - // flips without any further interaction; the roster trigger follows. - const idle = page.getByRole('button', { name: '1 background job', exact: true }) - await idle.waitFor({ timeout: 20_000 }) + // The unchanged count label is not a settlement signal; the output row owns that state. + await page.getByRole('button', { name: 'Finished 1', exact: true }).waitFor({ timeout: 20_000 }) + await expect.poll(() => page.getByRole('list', { name: 'Background jobs' }).textContent()).toContain('web e2e cancellation') + expect(await trigger.getAttribute('aria-label')).toBe('1 background job') + expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('killed') await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index d7fa2b8873..7f31d0a3fe 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -102,6 +102,13 @@ describe('web e2e: settled Markdown math rendering', () => { tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.getByText(DONE, { exact: true }).waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { @@ -111,14 +118,6 @@ describe('web e2e: settled Markdown math rendering', () => { it.skipIf(MODE === 'record')('renders the settled reply without KaTeX errors', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) @@ -141,4 +140,27 @@ describe('web e2e: settled Markdown math rendering', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) }, 60_000) + + it.skipIf(MODE === 'record')('copies a selection with formulas as TeX source', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-math-copy')) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + const sentinel = 'clipboard-before-copy' + await page.evaluate(value => navigator.clipboard.writeText(value), sentinel) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(sentinel) + await page.evaluate(() => { + const paragraph = [...document.querySelectorAll('p')].find(item => item.textContent.startsWith('Inline dollar'))! + const visual = paragraph.parentElement!.querySelector('.katex-display .katex-html')! + const range = document.createRange() + range.setStart(paragraph.firstChild!, 0) + range.setEnd(visual.firstChild!, 1) + document.getSelection()!.removeAllRanges() + document.getSelection()!.addRange(range) + }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe( + 'Inline dollar $\\theta$ and backslash $\\frac{1}{5}$.\n\n$$\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}$$', + ) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 30_000) }) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index ee65497459..8a131ed29b 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -142,8 +142,11 @@ describe('minimal agent preset', () => { ], } `) + const requestedTools = new Set(requestHeader.tools?.map(tool => tool.name)) expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name))) - .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name))) + .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent) + .filter(tool => requestedTools.has(tool.name)) + .toSorted((left, right) => left.name.localeCompare(right.name))) }) it.skipIf(MODE === 'record')('expands the completed persistent Bash call in the Web conversation', async () => { diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index d36008959d..1b5a5dcbed 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -2,7 +2,7 @@ // wire — the one add card offers the dormant pi-ai catalog on its third-party // mode and the create form on its custom-API mode, a blank key saves a // reference-free profile for provider-native auth, and typing an API key later -// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) +// stores it under the derived reference (`MINIMAX_CN_API_KEY`) // while the settings document records only that reference. Each saved row // appears after route topology invalidation without presenting liveness as // provider status. The customized-settings fold writes its curated fields — @@ -154,8 +154,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 }) await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false) - // Clearing it restores submit: an empty field means "keep what is stored", - // never a refusal, or editing any other setting would demand the key. + // An empty field leaves this new profile on native authentication. await key.fill('') await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true) expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0) @@ -221,7 +220,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - await dialog.getByPlaceholder('留空使用已保存凭据', { exact: true }).waitFor({ timeout: 10_000 }) + const savedKey = dialog.getByLabel('API 密钥', { exact: true }) + await expect.poll(() => savedKey.inputValue(), { timeout: 10_000 }).toBe('sk-e2e-minimax') + expect(await savedKey.getAttribute('type')).toBe('password') expect(await dialog.textContent()).not.toContain('API 密钥来源:') expect(await dialog.textContent()).not.toMatch(/oauth/i) await dialog.getByText('自定义设置').click() diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts index 884adcb9d3..a45a5b85d7 100644 --- a/apps/web/tests/notice-wake-tail.e2e.ts +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -170,6 +170,7 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { kind: 'subagent-settled', form: 'notice', summary: 'Background subagent finished.', + stopReason: 'completed', senderSessionId: SessionId('notice-wake-child'), } const notice = createUserMessage({ diff --git a/apps/web/tests/official-subagent-bundle.e2e.ts b/apps/web/tests/official-subagent-bundle.e2e.ts index 8a21671b29..ef6268cd42 100644 --- a/apps/web/tests/official-subagent-bundle.e2e.ts +++ b/apps/web/tests/official-subagent-bundle.e2e.ts @@ -7,12 +7,13 @@ import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { startMockLlmServer, type MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { parseSessionHeader } from '@deepseek-ai/dsh-llm-replay' import type {} from '@deepseek-ai/dsh-plugin-manager' import type {} from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-subagent' import { assertFinalWorkspaceSnapshot, assertFixtureInventory, captureExpandedTurnProcessAria, compareOrRefreshGolden, - fixtureUserPrompts, launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, + fixtureUserPrompts, launchWebScaffold, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -137,6 +138,14 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl const sessionId = await settled const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error('Native delegation Session has no live Agent') + const firstTurnEvents = agent.session.snapshotEvents() + const toolResults = firstTurnEvents.filter(event => event.type === 'tool/result').map(event => event.data) + expect(toolResults, `PTC execution must start the native child before waiting for its result: ${JSON.stringify(toolResults)}`) + .toMatchObject([{ message: { isError: false } }]) + const dispatches = firstTurnEvents.filter(event => event.type === 'tool/ptc-dispatch').map(event => event.data) + expect(dispatches).toMatchObject([{ name: 'subagent_claude_code', isError: false }]) + const catalog = firstTurnEvents.filter(event => event.type === 'subagent/catalog').map(event => event.data) + expect(catalog).toMatchObject([{ mode: 'external', label: 'Read the fixture response' }]) expect(server.requests).toHaveLength(0) const completed = scaffold.whenTurnSettled(120_000) nativeEndpoint.release() @@ -148,10 +157,6 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl expect(system).toContain('subagent_claude_code') const events = agent.session.snapshotEvents() expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(['run_code']) - const dispatches = events.filter(event => event.type === 'tool/ptc-dispatch').map(event => event.data) - expect(dispatches).toMatchObject([{ name: 'subagent_claude_code', isError: false }]) - const catalog = events.filter(event => event.type === 'subagent/catalog').map(event => event.data) - expect(catalog).toMatchObject([{ mode: 'external', label: 'Read the fixture response' }]) const notice = events.filter(event => event.type === 'user/message').find(event => event.data.source.kind === 'subagent-settled') if (notice?.data.source.kind !== 'subagent-settled') throw new Error('Native completion was not delivered to the parent') expect(notice.data.source.senderSessionId).toBe(catalog[0]?.childId) @@ -178,3 +183,31 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl ]) }) }) + +it.skipIf(MODE === 'record')('renders recorded official notice titles after reopening the Session', async () => { + const scaffold = await launchWebScaffold({ developerTools: true }) + let browser: Browser | undefined + try { + const fixture = await readFile(await selectedSessionFixture(FIXTURE), 'utf8') + await seedSession(scaffold, fixture, 'official-subagent-recorded-ui') + browser = await chromium.launch() + const page = await newEnglishPage(browser) + await page.clock.setFixedTime(parseSessionHeader(fixture).createdAt) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const workspace = page.locator('[role="treeitem"]').first() + await workspace.waitFor() + await workspace.click() + const session = page.locator('[role="treeitem"]').nth(1) + await session.waitFor() + await session.click() + const heading = page.locator('[data-turn-trigger] > button > span').nth(1) + await expect.poll(() => heading.textContent()).toBe('Subagent run completed') + await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), + await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 8706a7a4de..06e451032d 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -2,7 +2,6 @@ // credential is absent, both ordered steps share the shipped modal chrome, // and the inline key write lands in an isolated harness home without a reload // or model call. -import { randomBytes } from 'node:crypto' import { assertModelInputLayout } from './model-input-layout.ts' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -33,6 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup let page: Page let tripwire: ReturnType const browserConsole: string[] = [] + const secret = 'dsh_onboarding_fixture_key' beforeAll(async () => { scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true }) @@ -50,7 +50,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await scaffold?.close() }) - it('stores a key write-only and observes configured state without restarting', async () => { + it('stores a key and loads it masked only in an explicitly opened editor', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config')) const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }) await welcome.waitFor({ timeout: 15_000 }) @@ -86,7 +86,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE) - const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}` await keyInput.fill(secret) await credentialStep.getByRole('button', { name: '保存并继续' }).click() await credentialStep.waitFor({ state: 'detached', timeout: 15_000 }) @@ -101,8 +100,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`) - // The ordinary Models surface reuses the refreshed join and exposes the - // configured write-only placeholder without a reload. + // Opening the ordinary Models editor loads the saved key masked. await openSettings(page, 'zh') const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) @@ -113,9 +111,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup const configuredInput = settings.getByLabel('API 密钥', { exact: true }) await configuredInput.waitFor({ timeout: 10_000 }) await expect.poll( - () => configuredInput.getAttribute('placeholder'), + () => configuredInput.inputValue(), { timeout: 10_000 }, - ).toBe('留空使用已保存凭据') + ).toBe(secret) + expect(await configuredInput.getAttribute('type')).toBe('password') const secondReloadWarnings = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) @@ -255,9 +254,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() await expect.poll( - () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), + () => settings.getByLabel('API 密钥', { exact: true }).inputValue(), { timeout: 10_000 }, - ).toBe('留空使用已保存凭据') + ).toBe(secret) const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 527a1bcc73..51903b5633 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -9,7 +9,6 @@ import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { FiberState } from '@deepseek-ai/cordis' import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' -import { createUserMessage } from '@deepseek-ai/dsh-llm' import { HIDDEN_BUNDLES } from '@deepseek-ai/dsh-official-bundles' import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { join } from 'node:path' @@ -857,9 +856,12 @@ describe('web e2e: plugin manager', () => { .toBe(bundles.filter(bundle => !HIDDEN_BUNDLES.has(bundle.name) && bundle.name !== '@deepseek-ai/dsh-experimental-liveshare' && bundle.official && (bundle.listedByDefault !== false || bundle.enabled || bundle.installed)).length) + // Computer Use ships with the installation and is listed only on the macOS Hosts it supports. + const cuaSupported = process.platform === 'darwin' expect(bundles.find(bundle => bundle.name === '@deepseek-ai/dsh-cua-bundle')) - .toMatchObject({ enabled: false, optional: false, availability: 'missing', listedByDefault: false }) - expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) + .toMatchObject({ enabled: false, optional: true, availability: 'installation', ...cuaSupported ? {} : { listedByDefault: false } }) + expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()) + .toBe(cuaSupported ? 1 : 0) for (const name of ['@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle']) { expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === name)) .toMatchObject({ enabled: false, optional: true, availability: 'installation', listedByDefault: false }) @@ -870,13 +872,13 @@ describe('web e2e: plugin manager', () => { await card.getAttribute('data-plugin-package'), await card.getByText('实验性', { exact: true }).count(), ]))).toEqual([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', 1], ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], ['@deepseek-ai/dsh-experimental-auto-review', 1], ['@deepseek-ai/dsh-experimental-forge-bundle', 1], ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], + ...cuaSupported ? [['@deepseek-ai/dsh-cua-bundle', 1]] : [], ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1], ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1], ['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1], @@ -973,7 +975,7 @@ describe('web e2e: plugin manager', () => { onTestFinished(closeSettings) const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` const fallbackIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/fallback-icon.svg'))).toString('base64')}` - const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` + const voiceIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle/icon.svg', import.meta.url)))).toString('base64')}` const images: string[] = [] const checkImage = async (selector: string, source: string, label: string) => { const image = panel.locator(`${selector} img`).first() @@ -987,9 +989,9 @@ describe('web e2e: plugin manager', () => { images.push(`${label}: image, ${size.width}×${size.height}, decoded`) } await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') - const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]') + const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]') expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') - await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card') + await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]', voiceIcon, 'Disabled Voice Input card') try { for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) @@ -1003,8 +1005,8 @@ describe('web e2e: plugin manager', () => { } finally { await page.emulateMedia({ colorScheme: null }) } - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() - await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') + await panel.getByRole('button', { name: '查看 语音输入', exact: true }).click() + await checkImage('[data-plugin-detail]', voiceIcon, 'Voice Input detail') await panel.getByRole('button', { name: '返回插件' }).click() await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') @@ -1149,23 +1151,18 @@ describe('web e2e: plugin manager', () => { onTestFinished(closeSettings) onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale')) const panel = await openPluginsPanel() - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + await panel.getByRole('button', { name: '查看 语音输入', exact: true }).click() const packageName = panel.locator('[data-plugin-name]') - expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText(experimentalBundleDescription('agent-team-profile', 'zh')).count()).toBe(1) - const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' }) - await child.getByText('团队工具', { exact: true }).waitFor() - expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) + expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-voice-input-bundle') + expect(await panel.getByText('在本机使用 SenseVoice 转写录音,首次使用需安装依赖。').count()).toBe(1) try { await setLanguage('en') - await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor() - expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') - expect(await panel.getByText(experimentalBundleDescription('agent-team-profile', 'en')).count()).toBe(1) - await child.getByText('Team tools', { exact: true }).waitFor() - expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) + await panel.getByRole('heading', { name: 'Voice input', exact: true }).waitFor() + expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-voice-input-bundle') + expect(await panel.getByText('Transcribe recordings locally with SenseVoice; first use requires installing dependencies').count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() - await panel.getByRole('button', { name: 'View Agent teams', exact: true }).waitFor() - expect(await panel.getByRole('switch', { name: 'Enable Agent teams', exact: true }).count()).toBe(1) + await panel.getByRole('button', { name: 'View Voice input', exact: true }).waitFor() + expect(await panel.getByRole('switch', { name: 'Enable Voice input', exact: true }).count()).toBe(1) // Schedule ships in the delivered composition, so no bundle card carries it in either language. expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0) // The official configuration pages follow the language too, from their own dictionary. @@ -1175,69 +1172,35 @@ describe('web e2e: plugin manager', () => { } finally { await setLanguage('zh') } - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor() + await panel.getByRole('button', { name: '查看 语音输入', exact: true }).waitFor() expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('enables the Team tools and browser plugin with one bundle switch', async () => { + it('provides Team mode per Session without an experimental plugin switch', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-team')) + onTestFinished(closeSettings) const panel = await openPluginsPanel() - const toggle = panel.getByRole('switch', { name: '启用 智能体团队', exact: true }) - const teamRows = () => [...scaffold.ctx.loader.entries()] - .filter(entry => ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id)) + expect(await panel.getByRole('switch', { name: '启用 智能体团队', exact: true }).count()).toBe(0) + expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]').count()).toBe(0) + expect([...scaffold.ctx.loader.entries()].filter(entry => + ['agent-team', 'tool-agent-team', 'ui-agent-team'].includes(entry.options.id) + && entry.fiber?.state === FiberState.ACTIVE)).toHaveLength(3) const teamPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const teamTripwire = watchConsole(teamPage) try { await teamPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspaceZh(teamPage, scaffold.workspaceCwd) - const agent = scaffold.ctx.agents.list()[0] - if (agent === undefined) throw new Error('connected Team workspace did not create an Agent') - // Session actions render only after the conversation leaves its blank state. - agent.session.append('turn/start', { turn: 1 }) - agent.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'Team UI lifecycle' }], source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await scaffold.ctx.sessions.flush(agent.session) - // The current crumb renders as plain text inside the zh-labeled hierarchy nav. - await teamPage.getByRole('navigation', { name: '会话层级' }) - .getByText('Team UI lifecycle', { exact: true }).waitFor() - const action = teamPage.locator('[data-team-action]') - expect(await action.count()).toBe(0) - await toggle.click() - try { - await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3) - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') - await action.waitFor({ timeout: 20_000 }) - await action.getByRole('button', { name: '智能体团队', exact: true }).click() - const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true }) - await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor() - await teamPage.reload({ waitUntil: 'load' }) - await action.getByRole('button', { name: '智能体团队', exact: true }).click() - await teamPanel.getByText('暂无共享任务,可以通过对话创建').waitFor() - await teamPanel.getByText('lead', { exact: true }).waitFor() - const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { - dsh: { profile: { bundles: string[] } } - } - expect(manifest.dsh.profile.bundles).toEqual([...SCAFFOLD_BUNDLES, '@deepseek-ai/dsh-experimental-agent-team-profile']) - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() - for (const id of ['agent-team', 'tool-agent-team', 'ui-agent-team']) { - await panel.locator('[data-plugin-row]', { hasText: id }).first().waitFor() - } - await panel.getByRole('button', { name: '返回插件' }).click() - } finally { - const back = panel.getByRole('button', { name: '返回插件' }) - if (await back.count() > 0) await back.click() - if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click() - await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(0) - await expect.poll(() => action.count(), { timeout: 20_000 }).toBe(0) - } + await teamPage.getByRole('button', { name: /^选择模型/u }).click() + const mode = teamPage.getByRole('switch', { name: '启用智能体团队协作', exact: true }) + await mode.waitFor() + expect(await mode.getAttribute('aria-checked')).toBe('false') + await mode.click() + await expect.poll(() => mode.getAttribute('aria-checked')).toBe('true') expect(teamTripwire.pageErrors).toEqual([]) expect(teamTripwire.warnings).toEqual([]) } finally { await teamPage.close() } - expect(tripwire.pageErrors).toEqual([]) }, 60_000) it('mounts the delivered Automation tasks rows without a bundle switch', async () => { @@ -1519,8 +1482,8 @@ describe('web e2e: plugin manager', () => { // on, so writing the patch file recomposes the running loader; each step first waits for that // recomposition and then re-reads the Host bundle list through the page's own Refresh. const patchFile = join(visibilityScaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml') - const declarationsRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()] - .some(entry => entry.options.id === 'liveshare-viewer-declarations') + const shareHostRow = (): boolean => [...visibilityScaffold.ctx.loader.entries()] + .some(entry => entry.options.id === 'liveshare-share-host') const refresh = panel.getByRole('button', { name: '刷新', exact: true }) // Default install: the package ships, but no row it declares runs, so neither list offers it. @@ -1538,12 +1501,12 @@ describe('web e2e: plugin manager', () => { // selects no such bundle: the card joins the experimental list. await writeFile(patchFile, [ '- insert:', - ' - id: liveshare-viewer-declarations', - " name: '@deepseek-ai/dsh-experimental-liveshare/viewer-declarations'", + ' - id: liveshare-share-host', + " name: '@deepseek-ai/dsh-experimental-liveshare'", '', ].join('\n')) - await expect.poll(declarationsRow, { - timeout: 30_000, message: 'the profile patch did not compose the LiveShare declarations row', + await expect.poll(shareHostRow, { + timeout: 30_000, message: 'the profile patch did not compose the LiveShare share-host row', }).toBe(true) await refresh.click() await expect.poll(() => card.count(), { @@ -1552,8 +1515,8 @@ describe('web e2e: plugin manager', () => { // Removing the row stops it: the card leaves both lists again. await writeFile(patchFile, '[]\n') - await expect.poll(declarationsRow, { - timeout: 30_000, message: 'the profile patch did not remove the LiveShare declarations row', + await expect.poll(shareHostRow, { + timeout: 30_000, message: 'the profile patch did not remove the LiveShare share-host row', }).toBe(false) await refresh.click() await expect.poll(() => card.count(), { diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 302aea3fdc..cf2aac31b8 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -76,10 +76,13 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) - expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ - { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, - { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, - ]) + { + using observed = await scaffold.ctx.sessionQuery.observeSession(id) + expect(observed.projections?.values.subagentCatalog).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + } const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 3cf7695139..f1ea40b25c 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -694,7 +694,6 @@ async function toggleOptionalBundles(page: Page): Promise { }) } return { - cua: bundles.filter(entry => entry.name === '@deepseek-ai/dsh-cua-bundle'), optional: bundles.filter(entry => entry.optional).map(entry => ({ name: entry.name, enabled: entry.enabled, availability: entry.availability, error: entry.error ?? null, title: typeof entry.meta?.title, icon: typeof entry.meta?.icon, metaError: entry.meta?.error ?? null, @@ -706,16 +705,19 @@ async function toggleOptionalBundles(page: Page): Promise { }) expect(result.optional.map(entry => entry.name).sort()).toEqual([...OPTIONAL_BUNDLES].sort()) expect(result.optional.filter(entry => entry.enabled || entry.availability !== 'installation')).toEqual([]) - expect(result.optional.filter(entry => entry.error !== null)).toEqual([]) - expect(result.cua).toHaveLength(1) - expect(result.cua[0]).toMatchObject({ name: '@deepseek-ai/dsh-cua-bundle', optional: false, - availability: 'missing', error: { code: 'unsupported-platform' } }) + expect(result.optional.filter(entry => entry.error !== null)).toEqual([ + expect.objectContaining({ name: '@deepseek-ai/dsh-cua-bundle', error: { code: 'unsupported-platform' } }), + ]) // Metadata resolves through the Worker's Node internal modules: every optional bundle shows its title and icon. expect(result.optional.filter(entry => entry.metaError !== null || entry.title !== 'object' || entry.icon !== 'string')).toEqual([]) expect(result.inventoryErrors).toEqual([]) console.info(`preview boot: optional bundle toggles ${JSON.stringify(result.toggles.map(({ name, enable, disable, error }) => ({ name, enable, disable, error })))}`) - expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toBeUndefined() - const supported = result.toggles + expect(result.toggles.find(toggle => toggle.name === '@deepseek-ai/dsh-cua-bundle')).toMatchObject({ + enable: 'failed', disable: 'applied', error: { code: 'unsupported-platform' }, + changed: 0, inactive: [], restored: true, + }) + // Computer Use refuses enablement on this non-macOS worker Host, as asserted above, so the generic check omits it. + const supported = result.toggles.filter(toggle => toggle.name !== '@deepseek-ai/dsh-cua-bundle') expect(supported.filter(toggle => toggle.enable !== 'applied' || toggle.disable !== 'applied') .map(({ name, enable, disable }) => ({ name, enable, disable }))).toEqual([]) // Each bundle changes Loader rows, every enabled row it changes runs, and switching it off restores the prior rows. diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 407f29883e..a7e5afd77f 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1776,10 +1776,12 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P /** * Console tripwires: reconnect/gap-repair self-healing or a pageerror must - * fail the scenario, not mask a dead wire behind eventual consistency. + * fail the scenario, not mask a dead wire behind eventual consistency. A boot + * audit warning fails it too: the product boot tolerates inactive optional + * entries, but every e2e composition must activate completely. */ export interface WebConsoleTripwire { - /** Console warnings matching reconnect/gap-repair/discontinuity copy. */ + /** Console warnings matching reconnect/gap-repair/discontinuity copy or the boot audit's inactive-entry warning. */ warnings: string[] /** Uncaught page errors. */ pageErrors: string[] @@ -1795,7 +1797,7 @@ export function watchConsole(page: Page): WebConsoleTripwire { const pageErrors: string[] = [] page.on('console', (message) => { const text = message.text() - if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text) + if (/connection lost|gap repair|discontinuous|web boot: warning/i.test(text)) warnings.push(text) }) page.on('pageerror', (error) => { pageErrors.push(String(error)) }) return { warnings, pageErrors } diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 3ea259d0e3..1e0ae9dfad 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -497,8 +497,13 @@ const EXPECTED_TOOLS = [ 'skill', 'subagent', 'subagent_fork', + 'team_task_create', + 'team_task_get', + 'team_task_list', + 'team_task_update', 'todo_write', 'update_goal', + 'wait_agent', 'web_fetch', 'web_search', 'workflow', @@ -592,8 +597,10 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async "mode": "always", } `) - // The directory tool is global; the deployment's default preset supplies the agent's remaining tools. - expect(ctx.tools.schemas().map(schema => schema.name)).toEqual(['working_directory']) + // Directory and conditional Team tools are global; presets supply the remaining registrations. + expect(ctx.tools.schemas().map(schema => schema.name)).toEqual([ + 'working_directory', 'team_task_create', 'team_task_list', 'team_task_get', 'team_task_update', + ]) const handle = await ctx.agents.create({ sessionId: SessionId('shipped-composition'), setup: agentCtx => ctx.agentPresets.mount(agentCtx).then(() => undefined), diff --git a/apps/web/tests/sidebar-alignment.e2e.ts b/apps/web/tests/sidebar-alignment.e2e.ts index 82afdd29f2..ed5fced520 100644 --- a/apps/web/tests/sidebar-alignment.e2e.ts +++ b/apps/web/tests/sidebar-alignment.e2e.ts @@ -1,5 +1,7 @@ /** Browser geometry for sidebar controls, text clearance, and drag previews. */ -import { readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' @@ -179,6 +181,44 @@ describe('web e2e: sidebar search alignment and row action targets', () => { expect(tripwire.warnings).toEqual([]) }) + it.each(['Search sessions', 'View options', 'Add workspace'])('keeps the %s tooltip visible across its full bounds', async (name) => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-toolbar-tooltip')) + const trigger = page.getByRole('button', { name, exact: true }) + await trigger.hover() + const tooltip = page.getByRole('tooltip') + await tooltip.waitFor({ state: 'visible', timeout: 10_000 }) + const bounds = await tooltip.evaluate((element: HTMLElement) => { + const rect = element.getBoundingClientRect() + const sidebar = document.querySelector('[class*="sidebarCol"]')!.getBoundingClientRect() + const originalStyle = element.getAttribute('style') + try { + // Tooltips ignore pointer input; enabling it only for this read exposes ancestor clipping. + element.style.pointerEvents = 'auto' + const points = [ + [rect.left + 2, rect.top + rect.height / 2], + [rect.right - 2, rect.top + rect.height / 2], + [rect.left + rect.width / 2, rect.top + 2], + [rect.left + rect.width / 2, rect.bottom - 2], + ] + return { + outsideSidebar: rect.right > sidebar.right, + hits: points.map(([x, y]) => { + const hit = document.elementFromPoint(x!, y!) + return hit !== null && element.contains(hit) + }), + } + } finally { + if (originalStyle === null) element.removeAttribute('style') + else element.setAttribute('style', originalStyle) + } + }) + if (name === 'Add workspace') expect(bounds.outsideSidebar).toBe(true) + expect(bounds.hits).toEqual([true, true, true, true]) + await page.mouse.move(1000, 400) + await tooltip.waitFor({ state: 'detached' }) + expect(tripwire.pageErrors).toEqual([]) + }) + it('interpolates header spacing with the search expansion and collapse', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-transition')) const header = page.locator('[class*="sectionHeader"]').filter({ has: page.getByRole('button', { name: 'Search sessions', exact: true }) }) @@ -368,6 +408,55 @@ describe('web e2e: sidebar search alignment and row action targets', () => { expect(tripwire.pageErrors).toEqual([]) }) + it('spans the first root workspace with its insertion marker during a native drag', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-first-workspace-drop')) + const directory = await mkdtemp(join(tmpdir(), 'dsh-sidebar-drag-')) + try { + const extra = await scaffold.ctx.workspaceRegistry.create(directory, 'Sidebar drag workspace') + try { + const rows = page.locator('[role="tree"][aria-label="Sessions"] [data-row-key^="workspace:"]') + await expect.poll(() => rows.count()).toBe(2) + const before = await rows.evaluateAll(elements => elements.map(element => element.getAttribute('data-row-key'))) + await rows.last().hover() + const source = await rows.last().boundingBox() + const target = await rows.first().boundingBox() + if (source === null || target === null) throw new Error('Workspace drag rows have no layout boxes') + await page.mouse.move(source.x + 30, source.y + source.height / 2) + await page.mouse.down() + try { + await page.mouse.move(source.x + 40, source.y + source.height / 2, { steps: 3 }) + await page.mouse.move(target.x + 30, target.y + 2, { steps: 5 }) + await page.mouse.move(target.x + 31, target.y + 2) + const marker = page.locator('[class*="listTopDropIndicator"]') + await marker.waitFor({ state: 'attached', timeout: 10_000 }) + const geometry = await marker.evaluate((element) => { + const rect = element.getBoundingClientRect() + const tree = element.parentElement!.querySelector('[role="tree"]')!.getBoundingClientRect() + const first = element.parentElement!.querySelector('[data-row-key^="workspace:"]')!.getBoundingClientRect() + return { + width: rect.width, height: rect.height, + treeWidth: tree.width, leftInset: rect.left - first.left, + rightInset: first.right - rect.right, topOffset: rect.top - first.top, + } + }) + expect(geometry.width).toBeGreaterThan(0) + expect(geometry.width).toBe(geometry.treeWidth) + expect(geometry).toMatchObject({ height: 12, leftInset: 0, rightInset: 0, topOffset: -8 }) + } finally { + await page.mouse.up() + } + await expect.poll(() => rows.evaluateAll(elements => elements.map(element => element.getAttribute('data-row-key')))) + .toEqual(before.toReversed()) + } finally { + await scaffold.ctx.workspaceRegistry.delete(extra.id) + } + } finally { + await rm(directory, { recursive: true, force: true }) + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + it.each(['session', 'workspace'] as const)('retains %s row spacing in the native drag preview', async (kind) => { onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-drag-preview`)) const row = kind === 'session' @@ -496,7 +585,7 @@ describe('web e2e: sidebar search alignment and row action targets', () => { await row.getByRole('button', { name: labels[0], exact: true }).waitFor() const metrics = await measureRowActions(row) expect(await row.boundingBox()).toEqual(restingBox) - expect(metrics.height).toBe(kind === 'session' ? 32 : 34) + expect(metrics.height).toBe(32) expect(metrics.overflows).toBe(false) expect(metrics.actions.map(action => action.label)).toEqual(labels) for (const [index, action] of metrics.actions.entries()) { diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index afcedbf0ca..534a1292aa 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -56,7 +56,7 @@ interface ListMetrics { */ function measureList(page: Page): Promise { return page.evaluate(() => { - const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + const list = document.querySelector('[data-sidebar-list-area]') if (list === null) throw new Error('sidebar session list not in the DOM') const time = list.querySelector('[class*="time"]') if (time === null) throw new Error('no row relative-time element in the sidebar list') @@ -94,8 +94,8 @@ function measureList(page: Page): Promise { const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth) const listRect = list.getBoundingClientRect() - const listParentRight = list.parentElement?.getBoundingClientRect().right - if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent') + const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right + if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent') return { gutter: style.scrollbarGutter, width: pseudoWidth, @@ -107,9 +107,9 @@ function measureList(page: Page): Promise { hoverToken: resolve('--dsh-scrollbar-thumb-hover'), overflows: list.scrollHeight > list.clientHeight, band: listRect.width - list.clientWidth, - scrollbarEdgeOffset: listParentRight - listRect.right, - rowEdgeInset: listParentRight - row.getBoundingClientRect().right, - rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), + scrollbarEdgeOffset: sidebarEdge - listRect.right, + rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right, + rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), clientRight: listRect.left + list.clientWidth, borderRight: listRect.right, timeRight: time.getBoundingClientRect().right, @@ -133,16 +133,16 @@ function measureList(page: Page): Promise { */ function measureRowInset(page: Page): Promise> { return page.evaluate(() => { - const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + const list = document.querySelector('[data-sidebar-list-area]') if (list === null) throw new Error('sidebar session list not in the DOM') const row = list.querySelector('[role="treeitem"]') if (row === null) throw new Error('no row in the sidebar list') - const listParentRight = list.parentElement?.getBoundingClientRect().right - if (listParentRight === undefined) throw new Error('sidebar session list has no layout parent') + const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right + if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent') return { overflows: list.scrollHeight > list.clientHeight, - rowEdgeInset: listParentRight - row.getBoundingClientRect().right, - rowPaintInset: listParentRight - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), + rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right, + rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth), } }) } @@ -195,9 +195,9 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string { `- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`, `- list overflows: ${String(metrics.overflows)}`, `- reserved band: ${String(metrics.band)}px`, - `- scrollbar inset from the list parent edge: ${String(metrics.scrollbarEdgeOffset)}px`, - `- row hitbox inset from the list parent edge: ${String(metrics.rowEdgeInset)}px`, - `- row background inset from the list parent edge: ${String(metrics.rowPaintInset)}px`, + `- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`, + `- row hitbox inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`, + `- row background inset from the sidebar edge: ${String(metrics.rowPaintInset)}px`, `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`, `- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`, `- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`, @@ -220,7 +220,7 @@ function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string { */ function resolveThumb(page: Page): Promise { return page.evaluate(() => { - const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + const list = document.querySelector('[data-sidebar-list-area]') if (list === null) throw new Error('sidebar session list not in the DOM') const probe = document.createElement('span') probe.style.color = 'var(--dsh-scrollbar-thumb)' @@ -243,7 +243,7 @@ const NO_THUMB = 'rgba(0, 0, 0, 0)' * of the viewport (the conversation column). */ async function pointAt(page: Page, where: 'list' | 'away'): Promise { - const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox() + const box = await page.locator('[data-sidebar-list-area]').boundingBox() if (box === null) throw new Error('sidebar session list has no layout box') const viewport = page.viewportSize() if (viewport === null) throw new Error('page has no viewport') @@ -272,18 +272,63 @@ async function expandSeededSessions(page: Page): Promise { } const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ }) if (await bucket.getAttribute('aria-expanded') === 'true' - && await rows.count() <= SEED_COUNT / 2 + && await rows.count() < SEED_COUNT && await showMore.count() > 0) { await showMore.click() } - if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() > SEED_COUNT / 2) return + if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() >= SEED_COUNT) return if (Date.now() > deadline) { - throw new Error(`Ungrouped bucket never revealed more than ${SEED_COUNT / 2} rows`) + throw new Error(`Ungrouped bucket never revealed all ${SEED_COUNT} rows`) } await page.waitForTimeout(200) } } +/** + * Scroll positions of the sidebar viewport and the session list, with the fixed controls around them. + * @param page - the page under test. + * @returns both scroll offsets and ranges, plus the toolbar, navigation, and fixed-control edges. + */ +function nestedScrollGeometry(page: Page) { + return page.evaluate(() => { + const viewport = document.querySelector('[data-sidebar-scrollport]')! + const list = document.querySelector('[data-sidebar-list-area]')! + const column = viewport.closest('[class*="sidebarCol"]')! + const edge = (selector: string) => column.querySelector(selector)!.getBoundingClientRect() + return { + viewportTop: viewport.getBoundingClientRect().top, + viewportScroll: viewport.scrollTop, + viewportRange: viewport.scrollHeight - viewport.clientHeight, + listScroll: list.scrollTop, + listRange: list.scrollHeight - list.clientHeight, + headerTop: edge('[data-sidebar-workspace-header]').top, + navBottom: edge('[class*="panelList"]').bottom, + buttonTop: edge('button[class*="newSession"]').top, + footerTop: edge('[class*="footArea"]').top, + } + }) +} + +/** + * Return both scrollers to their tops. + * @param page - the page under test. + */ +async function resetNestedScroll(page: Page): Promise { + await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop = 0 }) + await page.locator('[data-sidebar-scrollport]').evaluate((el) => { el.scrollTop = 0 }) +} + +/** + * Park the pointer at the center of the first element matching `selector`. + * @param page - the page under test. + * @param selector - the element to point at. + */ +async function pointInto(page: Page, selector: string): Promise { + const box = await page.locator(selector).first().boundingBox() + if (box === null) throw new Error(`${selector} has no layout box`) + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) +} + describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => { let scaffold: WebScaffold let browser: Browser @@ -322,6 +367,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true) const metrics = await measureList(page) expect(metrics.gutter).toBe('stable') + const horizontal = await page.locator('[data-sidebar-scrollport]').evaluate(viewport => ({ + overflow: getComputedStyle(viewport).overflowX, + scrollbarHeight: viewport.getBoundingClientRect().height - viewport.clientHeight, + })) + expect(horizontal.overflow).toBe('hidden') + expect(horizontal.scrollbarHeight).toBe(0) // Pin presence, not width, because the width is platform-dependent. expect(metrics.band).toBeGreaterThan(0) expect(metrics.scrollbarEdgeOffset).toBe(2) @@ -354,7 +405,7 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum // leaves the column quiet. This is the one deliberate loss, and // it is pinned here rather than only described, so making a scroll // re-reveal the bar has to be a decision rather than a side effect. - await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 }) + await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop += 200 }) await page.waitForTimeout(500) expect(await resolveThumb(page)).toBe(NO_THUMB) await pointAt(page, 'list') @@ -362,16 +413,103 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps the row background inset when overflow disappears', async () => { + it('removes both scroll ranges without moving row edges when grouped content fits', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset')) + const viewport = page.locator('[data-sidebar-list-area]') + const height = await viewport.evaluate(el => el.scrollHeight) expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 8, rowPaintInset: 11 }) const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') await bucket.click() try { - await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false) + await expect.poll(async () => viewport.evaluate(el => el.scrollHeight)).toBeLessThan(height) + await expect.poll(async () => (await measureRowInset(page)).overflows).toBe(false) expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 8, rowPaintInset: 11 }) + // A section that fits leaves the sidebar viewport nothing to scroll, so the navigation stays. + expect((await nestedScrollGeometry(page)).viewportRange).toBe(0) } finally { await expandSeededSessions(page) + await viewport.evaluate((el) => { el.scrollTop = 0 }) + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('hands one downward wheel event from the navigation to the session list and back', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll')) + const size = page.viewportSize()! + try { + await page.setViewportSize({ width: size.width, height: 500 }) + await resetNestedScroll(page) + const before = await nestedScrollGeometry(page) + expect(before.viewportRange).toBeGreaterThan(0) + expect(before.listRange).toBeGreaterThan(60) + expect(before.headerTop).toBeGreaterThan(before.viewportTop) + await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]') + await page.mouse.wheel(0, before.viewportRange + 60) + await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(60) + const after = await nestedScrollGeometry(page) + expect(after.viewportScroll).toBe(before.viewportRange) + expect(after.headerTop).toBe(after.viewportTop) + expect(after.navBottom).toBeLessThanOrEqual(after.viewportTop) + expect(after.buttonTop).toBe(before.buttonTop) + expect(after.footerTop).toBe(before.footerTop) + await page.mouse.wheel(0, -110) + await expect.poll(async () => (await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange - 50) + expect((await nestedScrollGeometry(page)).listScroll).toBe(0) + } finally { + await page.setViewportSize(size) + await resetNestedScroll(page) + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('continues a wheel event that starts over the navigation into the session list', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-navigation')) + const size = page.viewportSize()! + try { + await page.setViewportSize({ width: size.width, height: 500 }) + await resetNestedScroll(page) + const before = await nestedScrollGeometry(page) + await pointInto(page, '[data-sidebar-scrollport] [class*="panelList"]') + await page.mouse.wheel(0, before.viewportRange + 30) + await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(30) + expect((await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange) + } finally { + await page.setViewportSize(size) + await resetNestedScroll(page) + } + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps the toolbar at the top through empty search results', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-search')) + const header = page.locator('[data-sidebar-workspace-header]') + const input = header.locator('input') + const size = page.viewportSize()! + try { + await page.setViewportSize({ width: size.width, height: 500 }) + await resetNestedScroll(page) + const range = (await nestedScrollGeometry(page)).viewportRange + await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]') + await page.mouse.wheel(0, range) + await expect.poll(async () => { + const geometry = await nestedScrollGeometry(page) + return geometry.headerTop === geometry.viewportTop + }).toBe(true) + const searchButton = await page.getByRole('button', { name: 'Search sessions', exact: true }).boundingBox() + if (searchButton === null) throw new Error('Search sessions has no layout box') + await page.mouse.click(searchButton.x + searchButton.width / 2, searchButton.y + searchButton.height / 2) + expect(await input.evaluate(el => el === document.activeElement)).toBe(true) + await input.fill('no-sidebar-scroll-matches-9f4c') + await page.locator('[class*="searchTree"]').locator('..').locator('[class*="empty"]').waitFor() + const filtered = await nestedScrollGeometry(page) + expect(filtered.viewportScroll).toBe(range) + expect(filtered.headerTop).toBe(filtered.viewportTop) + expect((await header.boundingBox())?.height).toBe(36) + } finally { + if (await input.isVisible()) { await input.fill(''); await input.press('Escape') } + await page.setViewportSize(size) + await expandSeededSessions(page) + await resetNestedScroll(page) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/sidebar-subagent-activity.e2e.ts b/apps/web/tests/sidebar-subagent-activity.e2e.ts index f2c8eb3434..7ae28dd478 100644 --- a/apps/web/tests/sidebar-subagent-activity.e2e.ts +++ b/apps/web/tests/sidebar-subagent-activity.e2e.ts @@ -101,10 +101,8 @@ describe('web e2e: sidebar subagent activity', () => { provider: 'spawn', label: 'sidebar activity child', signal: new AbortController().signal, - request: { - prompt: [{ type: 'text', text: 'Hold this delegated task open.' }], - parent: parentHandle.agent, - }, + prompt: [{ type: 'text', text: 'Hold this delegated task open.' }], + parent: parentHandle.agent, }) childId = started.childId await waitForRunningChild(scaffold, adapter, childId) @@ -147,10 +145,72 @@ describe('web e2e: sidebar subagent activity', () => { ) expect(await ownerRow.locator('[data-state="ongoing"]').count()).toBe(1) await ownerRow.click() - const runningTrigger = page.getByRole('button', { name: '1 subagent running' }) + const runningTrigger = page.getByRole('button', { name: '1 subagent', exact: true }) await runningTrigger.waitFor({ timeout: 10_000 }) expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1) - await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md']) + // This directly created owner has no tool preset; the fixture collects its own job. + scaffold.ctx.effect(() => scaffold.ctx.jobs.attachController('compact header fixture'), 'compact header job controller') + const completion = Promise.withResolvers<{ status: 'completed' }>() + const ownerId = parentHandle.agent.id + const jobId = scaffold.ctx.jobs.start({ + kind: 'bash', label: 'Concurrent background work', owner: ownerId, + run(handle) { + handle.append('Background work is running.\n') + return { done: completion.promise, cancel: () => { completion.resolve({ status: 'completed' }) } } + }, + }) + const viewport = page.viewportSize()! + try { + const jobTrigger = page.getByRole('button', { name: '1 background job', exact: true }) + await jobTrigger.waitFor() + expect(await jobTrigger.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe('Running') + const controls = [ + { trigger: runningTrigger, icon: '[data-icon="dsw-icon-users-outline-regular"]', count: '[data-subagent-count]' }, + { trigger: jobTrigger, icon: '[data-icon="dsw-icon-layers-outline-regular"]', count: '[data-job-count]' }, + ] + for (const { trigger, icon, count } of controls) { + expect(await trigger.locator(count).isVisible()).toBe(true) + expect(await trigger.locator('[data-state="ongoing"]').isVisible()).toBe(true) + expect(await trigger.locator(icon).isVisible()).toBe(false) + } + await runningTrigger.focus() + await page.setViewportSize({ width: 390, height: 700 }) + for (const { trigger, icon, count } of controls) { + await expect.poll(() => trigger.locator(count).isVisible()).toBe(false) + expect(await trigger.locator(icon).isVisible()).toBe(true) + expect(await trigger.locator('[data-state="ongoing"]').isVisible()).toBe(false) + } + expect(await runningTrigger.evaluate(element => element === document.activeElement)).toBe(true) + expect(await jobTrigger.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe('Running') + await compareOrRefreshGolden( + join(SNAPSHOT_DIR, 'compact-running.expected.md'), + await captureStableAria(page, 'header:has([data-subagent-action])', scaffold.workspaceCwd), + MODE, + ) + await saveFailureShot(page, 'web-e2e-compact-running-icons') + await runningTrigger.click() + const members = page.locator('[data-subagent-panel]') + await members.waitFor() + expect(await members.getByRole('treeitem').locator('[data-state="ongoing"]').isVisible()).toBe(true) + await page.keyboard.press('Escape') + await jobTrigger.click() + const jobs = page.getByRole('list', { name: 'Background jobs', exact: true }) + await jobs.waitFor() + expect(await jobs.locator('[data-state="ongoing"]').isVisible()).toBe(true) + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + for (const { trigger, icon, count } of controls) { + await expect.poll(() => trigger.locator(count).isVisible()).toBe(true) + expect(await trigger.locator('[data-state="ongoing"]').isVisible()).toBe(true) + expect(await trigger.locator(icon).isVisible()).toBe(false) + } + } finally { + completion.resolve({ status: 'completed' }) + await scaffold.ctx.jobs.wait(jobId, 5_000, ownerId) + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + } + await assertFixtureInventory(SNAPSHOT_DIR, ['owner-running.expected.md', 'compact-running.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a980c2d02d..e2992e2a63 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -606,6 +606,7 @@ describe('dsh web keyless CLI smoke', () => { it.each(['native', 'ptc'] as const)('selects the %s executor through profile overlays and ignores the retired environment selector', async (mode) => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) + const promptMarker = `WEB_EXECUTOR_${mode}_${randomUUID()}` interface PtcModeProviderRequest { system?: string @@ -621,7 +622,11 @@ describe('dsh web keyless CLI smoke', () => { request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest) + const parsed = JSON.parse(body) as PtcModeProviderRequest + const messages = JSON.stringify(parsed.messages ?? []) + if (messages.includes(promptMarker) && !messages.includes(TITLE_PROMPT_MARKER)) { + resolveProviderRequest(parsed) + } response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end(messagesResponse('done')) }) @@ -653,11 +658,19 @@ describe('dsh web keyless CLI smoke', () => { try { const baseUrl = await waitForReadyLine(child) const created = await remoteRpc<{ sessionId: string }>(baseUrl, 'session/create', { request: {} }) + // Title requests share the provider; exercise auxiliary-first arrival explicitly. + await fetch(`http://127.0.0.1:${address.port}`, { + method: 'POST', + body: JSON.stringify({ messages: [{ + role: 'user', + content: [{ type: 'text', text: `${TITLE_PROMPT_MARKER}\n${JSON.stringify([promptMarker])}` }], + }] }), + }).then(response => response.text()) await remoteRpc<{ accepted: true }>(baseUrl, 'session/prompt', { request: { requestId: randomUUID(), sessionId: created.sessionId, mode: 'queue', - content: [{ type: 'text', text: 'go' }], + content: [{ type: 'text', text: promptMarker }], } }) const captured = await Promise.race([ providerRequest, @@ -693,6 +706,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke let browser: Browser let page: Page const pageErrors: string[] = [] + // The product boot only warns about inactive optional entries; this lane requires the whole composition. + const bootWarnings: string[] = [] beforeAll(async () => { requireDist() @@ -729,6 +744,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke browser = await chromium.launch() page = await newEnglishPage(browser) page.on('pageerror', e => pageErrors.push(String(e))) + page.on('console', (message) => { + if (message.text().startsWith('web boot: warning')) bootWarnings.push(message.text()) + }) await page.goto(baseUrl, { waitUntil: 'load' }) }, 120_000) @@ -747,6 +765,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke onTestFailed(() => saveFailureShot(page, 'w5-cold-start')) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) expect(await page.locator('text=Failed to load plugins').count()).toBe(0) + expect(bootWarnings).toEqual([]) const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns) expect(template.split(' ').length).toBe(3) await screen(page, '01-cold-start') diff --git a/apps/web/tests/snapshots/agent-team-panel/collaboration.zh.expected.md b/apps/web/tests/snapshots/agent-team-panel/collaboration.zh.expected.md new file mode 100644 index 0000000000..07a6ac96dc --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/collaboration.zh.expected.md @@ -0,0 +1,17 @@ +## Menu + +- menu "模型与推理等级": + - menuitem "模型 DeepSeek-V4-Flash" + - text: 启用智能体团队协作 + - button "关于智能体团队协作" + - switch "启用智能体团队协作" [checked] + +## Confirmation + +- dialog "确认停用智能体团队协作?": + - heading "确认停用智能体团队协作?" [level=2] + - button "关闭" + - paragraph: 对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。 + - paragraph: 停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。 + - button "取消" + - button "确认停用" diff --git a/apps/web/tests/snapshots/agent-team-panel/command-error.en.expected.md b/apps/web/tests/snapshots/agent-team-panel/command-error.en.expected.md new file mode 100644 index 0000000000..f636b75b13 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/command-error.en.expected.md @@ -0,0 +1,8 @@ +## Failed receipt + +- 'button "Agent teamwork Failed: Use /team on or /team off."' + +## Raw error + +- 'button "Agent teamwork Failed: Use /team on or /team off." [expanded]' +- text: Use /team on or /team off. diff --git a/apps/web/tests/snapshots/agent-team-panel/command-error.zh.expected.md b/apps/web/tests/snapshots/agent-team-panel/command-error.zh.expected.md new file mode 100644 index 0000000000..198f3edb33 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/command-error.zh.expected.md @@ -0,0 +1,8 @@ +## Failed receipt + +- button "智能体团队协作 执行失败:Use /team on or /team off." + +## Raw error + +- button "智能体团队协作 执行失败:Use /team on or /team off." [expanded] +- text: Use /team on or /team off. diff --git a/apps/web/tests/snapshots/agent-team-panel/disable-confirmation.expected.md b/apps/web/tests/snapshots/agent-team-panel/disable-confirmation.expected.md new file mode 100644 index 0000000000..ae29ea02b2 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/disable-confirmation.expected.md @@ -0,0 +1,7 @@ +- dialog "Disable agent teamwork?": + - heading "Disable agent teamwork?" [level=2] + - button "Close" + - paragraph: Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions. + - paragraph: Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task. + - button "Cancel" + - button "Disable" diff --git a/apps/web/tests/snapshots/agent-team-panel/info.expected.md b/apps/web/tests/snapshots/agent-team-panel/info.expected.md new file mode 100644 index 0000000000..5e675607a5 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/info.expected.md @@ -0,0 +1 @@ +- tooltip "Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs." diff --git a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md index 57fbab1f84..3e8f7521b2 100644 --- a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md @@ -1,15 +1,26 @@ -- dialog "Agent team": - - heading "Members" [level=3] - - button "lead Current chat Inactive" [disabled] - - heading "Shared tasks 2" [level=3] - - article: - - strong: Agent task - - text: In progress - - paragraph: Created by the Team Lead - - text: "task-1Owner: leadWrite scopes: src/web" - - article: - - strong: Responsive task - - text: Pending - - paragraph: Check the task description before you start work. Check the task description before you start work. Check the task description before you start work. - - button "Show more" - - text: "task-2Owner: UnownedReady" +- dialog "Subagent sessions": + - heading "Subagents" [level=3] + - text: 1 external + - tree "Subagent sessions": + - 'treeitem "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher · External task" [disabled] [level=1]': "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher" + - region "Task board": + - heading "Shared tasks 1 completed · 1 in progress · 2 pending" [level=3] + - list: + - listitem: + - button "In progress Agent task": + - img "In progress" + - text: Agent task + - listitem: + - button "Completed Completed preparation": + - img "Completed" + - text: Completed preparation + - listitem: + - button "Pending Dependency task": + - img "Pending" + - text: Dependency task + - listitem: + - button "Blocked by dependencies Responsive task with a long title that remains fully readable when expanded at the minimum supported viewport" [expanded]: + - img "Blocked by dependencies" + - text: Responsive task with a long title that remains fully readable when expanded at the minimum supported viewport + - paragraph: Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. Check every dependency and write scope before you start work. + - paragraph: "Owner: Unowned" diff --git a/apps/web/tests/snapshots/agent-team-panel/task-only.expected.md b/apps/web/tests/snapshots/agent-team-panel/task-only.expected.md new file mode 100644 index 0000000000..6a663176b6 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/task-only.expected.md @@ -0,0 +1,11 @@ +- dialog "Subagent sessions": + - heading "Subagents" [level=3] + - paragraph: No subagents + - tree "Subagent sessions" + - region "Task board": + - heading "Shared tasks 1 pending" [level=3] + - list: + - listitem: + - button "Pending Plan before delegation": + - img "Pending" + - text: Plan before delegation diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index d5593a069b..9032c3413b 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,9 +1,14 @@ -- dialog "Agent team": - - heading "Members" [level=3] - - button "lead Current chat Inactive" [disabled] - - heading "Shared tasks 1" [level=3] - - article: - - strong: Agent task - - text: In progress - - paragraph: Created by the Team Lead - - text: "task-1Owner: leadWrite scopes: src/web" +- dialog "Subagent sessions": + - heading "Subagents" [level=3] + - text: 1 external + - tree "Subagent sessions": + - 'treeitem "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher · External task" [disabled] [level=1]': "Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. Researcher: investigate the construction and verify every bound before reporting the result. team-panel-researcher" + - region "Task board": + - heading "Shared tasks 1 in progress" [level=3] + - list: + - listitem: + - button "In progress Agent task" [expanded]: + - img "In progress" + - text: Agent task + - paragraph: Created by the root agent + - paragraph: "Owner: session-{{uuid}}" diff --git a/apps/web/tests/snapshots/subagent-summary/fork.expected.md b/apps/web/tests/snapshots/subagent-summary/fork.expected.md new file mode 100644 index 0000000000..c94fd252dd --- /dev/null +++ b/apps/web/tests/snapshots/subagent-summary/fork.expected.md @@ -0,0 +1,40 @@ +## Running + +- text: Running +- button "Fork subagent Review inherited conversation context" + +## Creation receipt and complete task + +- button "Fork subagent Review inherited conversation context" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review inherited conversation context + - term: ID + - definition: fork-worker + - group: + - text: Task + - list: + - listitem: + - paragraph: Summarize the earlier agreed requirements. Do not modify files. +- button "Inspect" + +## Restored creation receipt + +- button "Fork subagent Review inherited conversation context" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review inherited conversation context + - term: ID + - definition: fork-worker + - group: + - text: Task + - list: + - listitem: + - paragraph: Summarize the earlier agreed requirements. Do not modify files. +- button "Inspect" diff --git a/apps/web/tests/snapshots/subagent-summary/lifecycle.expected.md b/apps/web/tests/snapshots/subagent-summary/lifecycle.expected.md new file mode 100644 index 0000000000..105e201da9 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-summary/lifecycle.expected.md @@ -0,0 +1,52 @@ +## Preparing without description + +- text: Preparing tool call Create subagent + +## Preparing with partial description + +- text: Preparing tool call Create subagent Review subagent task summaries + +## Running + +- text: Running +- button "Create subagent Review subagent task summaries" + +## Created + +- button "Create subagent Review subagent task summaries" + +## Creation receipt and complete task + +- button "Create subagent Review subagent task summaries" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review subagent task summaries Additional description context + - term: ID + - definition: child-foo + - group: + - text: Task + - list: + - listitem: + - paragraph: Inspect the subagent tool card across its lifecycle. Check streaming arguments, creation receipts, and restored history. Return findings without changing unrelated files. +- button "Inspect" + +## Restored creation receipt + +- button "Create subagent Review subagent task summaries" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: Agent task description + - definition: Review subagent task summaries Additional description context + - term: ID + - definition: child-foo + - group: + - text: Task + - list: + - listitem: + - paragraph: Inspect the subagent tool card across its lifecycle. Check streaming arguments, creation receipts, and restored history. Return findings without changing unrelated files. +- button "Inspect" diff --git a/apps/web/tests/snapshots/subagent-summary/variants.expected.md b/apps/web/tests/snapshots/subagent-summary/variants.expected.md new file mode 100644 index 0000000000..b79b9010c4 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-summary/variants.expected.md @@ -0,0 +1,39 @@ +## fallback + +- button "Create subagent Review the fallback summary." [expanded] +- text: Recorded result +- list: + - listitem: + - text: Started + - term: ID + - definition: fallback-worker + - group: + - text: Task + - list: + - listitem: + - paragraph: Review the fallback summary. Keep the complete task in details. +- button "Inspect" + +## reply + +- button "Create subagent Check synchronous agent reply" [expanded] +- text: Recorded result +- list: + - listitem: + - text: Agent response + - paragraph: The synchronous agent found two issues. Both are documented in the report. + - group: + - text: Task + - list: + - listitem: + - paragraph: Review the synchronous report. Keep every instruction available. +- button "Inspect" + +## title-only + +- button "Create subagent" + +## error + +- text: Failed +- button "Create subagent Subagent capacity exceeded" diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 56d7f0e66b..2b9cae7b84 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -5,7 +5,7 @@ import { dirname, join } from 'node:path' import { zstdCompressSync } from 'node:zlib' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay' import { SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, @@ -32,7 +32,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-con const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url)) const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) -const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) +const GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL( './expected/subagent-conversation/migration-required-child.expected.md', import.meta.url, @@ -102,6 +102,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = let browser: Browser let page: Page let sidecarRoot: string + let parentId: SessionId let childId: SessionId let oneShotId: SessionId let grandchildId: SessionId @@ -139,8 +140,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - const parent = scaffold.ctx.agents.roots()[0] + const parent = scaffold.ctx.agents.list().filter(agent => agent.session.header.origin !== 'subagent')[0] if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') + parentId = parent.id const parentSettled = scaffold.whenTurnSettled() const parentInput = page.locator('[data-composer-input][contenteditable="true"]').first() await parentInput.fill(PARENT_PROMPT) @@ -152,10 +154,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = provider: 'spawn', label: LABEL, signal: new AbortController().signal, - request: { - prompt: [{ type: 'text', text: INITIAL_PROMPT }], - parent, - }, + prompt: [{ type: 'text', text: INITIAL_PROMPT }], + parent, }) childId = started.childId await waitForAgentToSettle(scaffold, childId) @@ -309,25 +309,23 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() - expect(await scaffold.ctx.subagents.listChildren(parent.id)).toMatchObject([ - { - id: childId, mode: 'continuable', label: LABEL, - }, - { - id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, - }, - ]) + { + using observed = await scaffold.ctx.sessionQuery.observeSession(parent.id) + expect(observed.projections?.values.subagentCatalog).toMatchObject([ + { + id: childId, mode: 'continuable', label: LABEL, + }, + { + id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL, + }, + ]) + } { using observed = await scaffold.ctx.sessionQuery.observeSession(childId) expect(observed.source).toBe('prepared') expect(observed.projections?.values.subagentCatalog).toEqual([ { id: grandchildId, createdAt: authoredAt, mode: 'continuable', label: NESTED_LABEL }, ]) - expect(await scaffold.ctx.subagents.listChildren(childId)).toMatchObject([ - { - id: grandchildId, mode: 'continuable', label: NESTED_LABEL, - }, - ]) } await waitForCacheRow(scaffold, childHeader) const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name) @@ -379,6 +377,65 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await writeComposerDraft(page, input, '') }) + it('enters the member tree after pointer or keyboard activation of its trigger', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-trigger-focus')) + const trigger = page.getByRole('button', { name: '2 subagents', exact: true }) + const panel = page.locator('[data-subagent-panel]') + try { + for (const activation of ['click', 'Enter', 'Space']) { + if (activation === 'click') await trigger.click() + else await trigger.press(activation) + await expect.poll(() => panel.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('ArrowDown') + expect(await panel.locator('[role="treeitem"]:not([aria-disabled="true"])').first() + .evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + } + } finally { + await page.locator('[data-composer-input]').first().hover() + } + }) + + it('shows complete descriptions and IDs without hover at desktop and touch widths', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-catalog-labels')) + const originalViewport = page.viewportSize()! + const originalUrl = page.url() + try { + for (const width of [originalViewport.width, 390]) { + await page.setViewportSize({ ...originalViewport, width }) + const trigger = page.getByRole('button', { name: '2 subagents', exact: true }) + await trigger.click() + const panel = page.locator('[data-subagent-panel]') + const row = panel.getByRole('treeitem', { name: new RegExp(LABEL) }) + const primary = row.getByText(LABEL, { exact: true }) + const secondary = row.getByText(childId, { exact: true }) + const primaryBounds = await primary.boundingBox() + const secondaryBounds = await secondary.boundingBox() + expect(primaryBounds).not.toBeNull() + expect(secondaryBounds).not.toBeNull() + expect(primaryBounds!.y + primaryBounds!.height).toBeLessThanOrEqual(secondaryBounds!.y) + for (const label of [primary, secondary]) { + expect(await label.evaluate(element => element.scrollWidth <= element.clientWidth + && element.scrollHeight <= element.clientHeight)).toBe(true) + } + expect(await row.getAttribute('aria-label')).toContain(`${LABEL} ${childId} `) + expect(await row.getByRole('button', { name: /description/u }).count()).toBe(0) + expect(await row.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).count()).toBe(1) + expect(await row.getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).count()).toBe(1) + expect(page.url()).toBe(originalUrl) + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(scaffold.ctx.agents.get(childId)).toBeUndefined() + await page.keyboard.press('Escape') + await panel.waitFor({ state: 'detached' }) + } + } finally { + await page.setViewportSize(originalViewport) + await page.locator('[data-composer-input]').first().hover() + } + }) + it('recovers a current-format cold child catalog cache miss without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree')) await page.getByRole('button', { name: '2 subagents' }).hover() @@ -388,6 +445,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const hit = document.elementFromPoint(rect.left + 8, rect.top + 8) return hit !== null && element.contains(hit) })).toBe(true) + const sectionHeader = page.locator('[data-subagent-section-header]') + expect(await sectionHeader.count()).toBe(0) const oneShotDisclosure = page.getByRole('button', { name: `Expand ${ONE_SHOT_LABEL} descendants`, }) @@ -396,22 +455,112 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1) expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') + const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + const disclosure = childRow.getByRole('button', { name: new RegExp(`^(Expand|Collapse) ${LABEL} descendants$`, 'u') }) + const beforeDisclosureUrl = page.url() + const clickDisclosureGutter = async (offset: number): Promise => { + const geometry = await disclosure.evaluate((button, xOffset) => { + const row = button.closest('[role="treeitem"]')! + const rowBounds = row.getBoundingClientRect() + const buttonBounds = button.getBoundingClientRect() + const glyph = getComputedStyle(button.querySelector('svg')!) + const target = { x: rowBounds.left + xOffset, y: rowBounds.bottom - 3 } + const pseudo = getComputedStyle(button, '::after') + const status = row.querySelector('[data-state]')!.getBoundingClientRect() + return { + target, + outsideButton: target.x < buttonBounds.left || target.x > buttonBounds.right + || target.y < buttonBounds.top || target.y > buttonBounds.bottom, + pseudo: { width: pseudo.width, height: pseudo.height, top: pseudo.top, bottom: pseudo.bottom, left: pseudo.left }, + rowHeight: rowBounds.height, + glyph: { width: glyph.width, height: glyph.height }, + transform: getComputedStyle(button).transform, + hitsDisclosure: document.elementFromPoint(target.x, target.y) === button, + missesNextRow: document.elementFromPoint(target.x, rowBounds.bottom + 1) !== button, + endsBeforeStatus: rowBounds.left + 28 <= status.left, + missesStatus: document.elementFromPoint(status.left + status.width / 2, status.top + status.height / 2) !== button, + } + }, offset) + expect(geometry.outsideButton, JSON.stringify(geometry)).toBe(true) + expect(geometry.pseudo).toEqual({ width: '28px', height: `${geometry.rowHeight}px`, top: '0px', bottom: '0px', left: '0px' }) + expect(geometry.glyph).toEqual({ width: '12px', height: '12px' }) + expect(geometry.transform).toBe('none') + expect(geometry.hitsDisclosure).toBe(true) + expect(geometry.missesNextRow).toBe(true) + expect(geometry.endsBeforeStatus).toBe(true) + expect(geometry.missesStatus).toBe(true) + const body = childRow.locator(':scope > div') + await childRow.getByText(LABEL, { exact: true }).hover() + expect(await body.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)') + await page.mouse.move(geometry.target.x, geometry.target.y) + expect(await body.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + await page.mouse.click(geometry.target.x, geometry.target.y) + expect(page.url()).toBe(beforeDisclosureUrl) + expect(await page.getByText(PARENT_PROMPT, { exact: true }).isVisible()).toBe(true) + expect(await page.locator('[data-sidebar-chat]').count()).toBe(0) + } const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') try { const projections = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/projections') - await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + await clickDisclosureGutter(3) await projections await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) + expect(await sectionHeader.count()).toBe(0) expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true) expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false) } finally { observe.mockRestore() } - const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) + await clickDisclosureGutter(26) + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ state: 'detached' }) + expect(await childRow.getAttribute('aria-expanded')).toBe('false') + await clickDisclosureGutter(3) + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor() + expect(await childRow.getAttribute('aria-expanded')).toBe('true') + const connector = await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).evaluate((row) => { + const rowBounds = row.getBoundingClientRect() + const dot = row.querySelector('[data-state]')! + const dotBounds = dot.getBoundingClientRect() + const slot = dot.parentElement!.getBoundingClientRect() + const horizontal = getComputedStyle(row, '::before') + const node = row.parentElement! + const spine = getComputedStyle(node, '::before') + const stroke = Number.parseFloat(horizontal.borderTopWidth) + const colorProbe = document.createElement('span') + colorProbe.style.cssText = 'display:none;color:var(--dsw-alias-border-l3)' + row.append(colorProbe) + let expectedColor: string + try { + expectedColor = getComputedStyle(colorProbe).color + } finally { + colorProbe.remove() + } + return { + rowTop: rowBounds.top, + dotCenter: dotBounds.top + dotBounds.height / 2, + lineCenter: rowBounds.top + Number.parseFloat(horizontal.top) + stroke / 2, + lineEnd: rowBounds.left + Number.parseFloat(horizontal.left) + Number.parseFloat(horizontal.width), + slotLeft: slot.left, + spineEnd: node.getBoundingClientRect().top + Number.parseFloat(spine.top) + Number.parseFloat(spine.height), + lastSibling: node.nextElementSibling === null, + stroke, + color: horizontal.borderTopColor, + spineColor: spine.borderLeftColor, + expectedColor, + } + }) + expect(connector.lastSibling).toBe(true) + expect(Math.abs(connector.lineCenter - connector.dotCenter), JSON.stringify(connector)).toBeLessThanOrEqual(0.5) + expect(Math.abs(connector.spineEnd - connector.dotCenter), JSON.stringify(connector)).toBeLessThanOrEqual(0.5) + expect(Math.abs(connector.lineEnd - connector.slotLeft), JSON.stringify(connector)).toBeLessThanOrEqual(0.5) + expect(connector.stroke).toBeGreaterThanOrEqual(0.5) + expect(connector.stroke).toBeLessThanOrEqual(1) + expect(connector.color).toBe(connector.expectedColor) + expect(connector.spineColor).toBe(connector.expectedColor) const childLabel = await childRow.getAttribute('aria-label') await page.waitForTimeout(1_100) expect(await childRow.getAttribute('aria-label')).toBe(childLabel) - expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).textContent()).toContain('continuable') + expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).getAttribute('aria-label')).toContain('continuable') expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( @@ -420,7 +569,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = scaffold.workspaceCwd, ) await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE) - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).getByText(grandchildId, { exact: true }).click() await page.getByText(NESTED_PROMPT).waitFor() const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}`, exact: true }).waitFor() @@ -435,7 +584,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('button', { name: '2 subagents' }).hover() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).click() const sidebarChat = page.locator('[data-sidebar-chat]') - await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 }) + await sidebarChat.getByText(/^Explain event sourcing in one sentence\./).waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( SIDEBAR_CHAT_EXPECTED, await captureStableAria(page, '[data-sidebar-chat]', scaffold.workspaceCwd), @@ -456,16 +605,34 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(LABEL, { exact: true }).click() await expect.poll( - () => page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).count(), + () => page.getByText(/^Explain event sourcing in one sentence\./).count(), { timeout: 15_000 }, ).toBe(1) if (scaffold.ctx.agents.get(childId) !== undefined) { throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`) } const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) - await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}` }).waitFor() + const currentCrumb = hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}` }) + await currentCrumb.waitFor() + const crumbGeometry = await Promise.all([hierarchy.getByRole('button').first(), currentCrumb].map(crumb => crumb.evaluate((element) => { + const style = getComputedStyle(element) + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let text = walker.nextNode() + while (text !== null && text.textContent?.trim() === '') text = walker.nextNode() + if (text === null) throw new Error('Breadcrumb has no title text') + const range = document.createRange() + range.selectNodeContents(text) + const bounds = range.getBoundingClientRect() + return { fontSize: style.fontSize, lineHeight: style.lineHeight, top: bounds.top, bottom: bounds.bottom } + }))) + expect(crumbGeometry.map(({ fontSize, lineHeight }) => ({ fontSize, lineHeight }))) + .toEqual([{ fontSize: '14px', lineHeight: '20px' }, { fontSize: '14px', lineHeight: '20px' }]) + expect(Math.abs(crumbGeometry[0]!.top - crumbGeometry[1]!.top), JSON.stringify(crumbGeometry)).toBeLessThanOrEqual(1) + expect(Math.abs(crumbGeometry[0]!.bottom - crumbGeometry[1]!.bottom), JSON.stringify(crumbGeometry)).toBeLessThanOrEqual(1) + expect(await hierarchy.getByRole('button', { name: '1 subagent', exact: true }) + .evaluate(element => getComputedStyle(element).fontSize)).toBe('12px') const sidebar = await captureStableAria( page, '[role="tree"][aria-label="Sessions"]', @@ -494,7 +661,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => requested, { timeout: 15_000 }).toBe(true) expect(await page.getByText('This subagent is read-only for now', { exact: true }).count()).toBe(0) expect(await page.getByRole('navigation', { name: 'Session hierarchy' }).count()).toBe(0) - expect(await page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).count()).toBe(0) + expect(await page.getByText(/^Explain event sourcing in one sentence\./).count()).toBe(0) releaseCatalog() await page.getByRole('button', { name: `Switch subagent: ${LABEL}` }).waitFor({ timeout: 15_000 }) const input = page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }) @@ -549,7 +716,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(tripwire.warnings).toEqual([]) }) - it('opens an unavailable persisted grandchild after recording the available child', async () => { + it('opens a persisted grandchild with input enabled while its creator is offline', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-grandchild')) await page.getByRole('button', { name: '1 subagent' }).hover() const tree = page.getByRole('tree', { name: 'Subagent sessions' }) @@ -562,7 +729,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const clickArea = nestedRow.locator(':scope > div') expect(await clickArea.count()).toBe(1) const [menuBox, clickAreaBox] = await Promise.all([ - tree.locator('..').boundingBox(), + page.locator('[data-subagent-panel]').boundingBox(), clickArea.boundingBox(), ]) expect(menuBox).not.toBeNull() @@ -570,27 +737,23 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - menuBox!.x), Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width), - // The outer menu padding insets the scrollable tree rows. - ]).toEqual([4, 4]) + // Menu padding plus the branchless row's one-pixel leading inset. + ]).toEqual([7, 6]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), MODE, ) - await nestedRow.click() - await page.getByText('The parent session is offline; reopen it to continue sending messages.').waitFor() - // The offline banner renders from the descriptor alone, so it says nothing - // about the transcript below it. The golden pins that transcript, and - // `captureStableAria` calls two identical polls stable — including two of - // "Loading history…". Wait for the message the golden asserts. + await nestedRow.getByText(grandchildId, { exact: true }).click() await page.getByText(NESTED_PROMPT).waitFor() + expect(await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).isEnabled()).toBe(true) const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) const crumbs = await hierarchy.getByRole('button').allTextContents() expect(crumbs.slice(-2)).toEqual([LABEL, NESTED_LABEL]) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await compareOrRefreshGolden( - UNAVAILABLE_GRANDCHILD_EXPECTED, + GRANDCHILD_EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE, ) @@ -602,7 +765,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem', { name: /Ask a research subagent to/ }) await parentSession.click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).getByText(oneShotId, { exact: true }).click() await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor() expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() }) @@ -613,7 +776,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .getByRole('treeitem', { name: /Ask a research subagent to/ }) .click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).waitFor() const forkResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/fork') @@ -642,7 +805,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor() expect(scaffold.ctx.agents.get(childId)).toBeUndefined() @@ -659,7 +822,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click() await page.getByRole('button', { name: '2 subagents' }).press('ArrowDown') - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() const input = page.locator('[data-composer-input][contenteditable="true"]').first() await input.waitFor() const promptResponse = page.waitForResponse(response => @@ -683,6 +846,56 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(forkId)).not.toBeUndefined() await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined() }) + + it('shows shared tasks only in the root conversation while keeping child navigation', async () => { + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('task-board fixture requires the live parent') + const enabled = scaffold.ctx.agentTeams.isEnabled(parent) + onTestFinished(() => scaffold.ctx.agentTeams.setEnabled(parent, enabled)) + await scaffold.ctx.agentTeams.setEnabled(parent, true) + await scaffold.ctx.agentTeams.createTask(parent, { + subject: 'Root-only shared task', description: 'Inspect from the root conversation.', writeScopes: [], + }) + await page.getByRole('tree', { name: 'Sessions' }) + .getByRole('treeitem', { name: /Ask a research subagent to/ }).click() + await page.getByRole('button', { name: '2 subagents', exact: true }).click() + await page.locator('[data-team-panel]').getByText('Root-only shared task', { exact: true }).waitFor() + const rootHeader = page.locator('[data-subagent-section-header]') + expect(await rootHeader.textContent()).toBe('Subagents1 completed\u2002·\u20021 not running') + expect(await rootHeader.getByRole('button').count()).toBe(0) + expect(await rootHeader.locator('[data-icon="dsw-icon-users-outline-regular"]').count()).toBe(1) + const progress = await rootHeader.textContent() + await page.getByRole('button', { name: `Expand ${LABEL} descendants`, exact: true }).click() + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor() + expect(await rootHeader.textContent()).toBe(progress) + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByText(childId, { exact: true }).click() + const switcher = page.getByRole('button', { name: `Switch subagent: ${LABEL}`, exact: true }) + await switcher.click() + const panel = page.locator('[data-subagent-panel]') + await panel.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).waitFor() + expect(await panel.locator('[data-team-panel]').count()).toBe(0) + expect(await panel.evaluate(element => element.getBoundingClientRect().width)).toBe(360) + const header = panel.locator('[data-subagent-section-header]') + expect(await header.count()).toBe(0) + expect(await switcher.textContent()).toBe(LABEL) + const currentRow = panel.getByRole('treeitem', { name: new RegExp(LABEL) }) + const currentCheck = currentRow.locator('[data-subagent-current]') + expect(await currentRow.getAttribute('aria-current')).toBe('true') + expect(await currentCheck.getAttribute('aria-hidden')).toBe('true') + expect(await currentCheck.evaluate(element => element.tagName)).toBe('SPAN') + expect(await currentCheck.locator('button, [role="button"], [tabindex]').count()).toBe(0) + expect(await currentRow.getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).count()).toBe(0) + const otherRow = panel.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }) + expect(await otherRow.locator('[data-subagent-current]').count()).toBe(0) + expect(await otherRow.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar`, exact: true }).isEnabled()).toBe(true) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/subagent-conversation/navigation-only.expected.md', import.meta.url)), + await captureStableAria(page, '[data-subagent-panel]', scaffold.workspaceCwd), MODE, + ) + await page.keyboard.press('Escape') + }) + + }) describe('web e2e: migration-required subagent catalog', () => { @@ -795,7 +1008,7 @@ describe('web e2e: migration-required subagent catalog', () => { expect(observe.mock.calls.some(([id]) => id === childId || id === grandchildId)).toBe(false) expect(projections).not.toHaveBeenCalled() - await childRow.click() + await childRow.getByText(childId, { exact: true }).click() await page.getByRole('button', { name: `Switch subagent: ${childLabel}`, exact: true }).waitFor() await page.getByRole('button', { name: '1 subagent', exact: true }).hover() const grandchildRow = tree.getByRole('treeitem', { name: new RegExp(grandchildLabel) }) diff --git a/apps/web/tests/subagent-fork-schedule.snapshot.ts b/apps/web/tests/subagent-fork-schedule.snapshot.ts index 1a674ab30b..00678b48f4 100644 --- a/apps/web/tests/subagent-fork-schedule.snapshot.ts +++ b/apps/web/tests/subagent-fork-schedule.snapshot.ts @@ -48,7 +48,7 @@ it.skipIf(MODE === 'record')('replays inherited schedule tools and refusals in a const activation = await scaffold.ctx.subagents.startActivation({ provider: 'fork', label: 'Check child reminder refusal', delivery: 'caller', signal: new AbortController().signal, - request: { parent, prompt: [...childPrompt.data.content] }, + parent, prompt: [...childPrompt.data.content], }) try { await activation.result diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 7afeaa9a10..8c5f8fb1ea 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -1,7 +1,7 @@ // Web e2e scenario: the composer's independent Stop interrupts a running // continuable child. The child holds its model turn open through a replay -// hang entry; the browser proves Send and Stop coexist, the parent-offline -// disabled-Send-with-Stop composer, the subagents/interruptByParent +// hang entry; the browser proves Send and Stop coexist regardless of the +// creator's list availability, the subagents/interruptByUser // (never session.cancel) transport, the parked follow-up, and the FIFO resume // on a waking send. // @@ -144,7 +144,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) - const root = scaffold.ctx.agents.roots()[0] + const root = scaffold.ctx.agents.list().filter(agent => agent.session.header.origin !== 'subagent')[0] if (root === undefined) throw new Error('fresh workspace did not publish its parent Agent') parent = root // The child's first model call claims the primary override and holds. @@ -153,7 +153,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co provider: 'spawn', label: LABEL, signal: new AbortController().signal, - request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + prompt: [{ type: 'text', text: INITIAL }], parent, }) childId = started.childId await waitFor(() => existsSync(readyFile), 'the held child turn to open') @@ -188,7 +188,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed') }) - it('interrupts the live child through the parent-offline composer', async () => { + it('queues and interrupts the live child despite its creator being listed offline', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline')) // The parent is unavailable in the Session list while the child stays live. const pattern = '**/api/session/list' @@ -210,31 +210,22 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: /1 subagent/ }).click() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.getByRole('textbox', { - name: 'Parent session offline; sending is unavailable but you can still stop the run', - }) + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).press('Enter') + const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 15_000 }) - await page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /) + await page.getByText(/^Explain event sourcing in one sentence\./) .waitFor({ timeout: 15_000 }) - expect(await input.isDisabled()).toBe(true) + expect(await input.isDisabled()).toBe(false) const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) expect(await stop.isEnabled()).toBe(true) - const send = page.getByRole('button', { name: 'Send message' }) - expect(await send.count()).toBe(1) - expect(await send.isDisabled()).toBe(true) - // Keep the continuable Activation resident after this first abort. The - // direct setup queue also proves the ordinary row controls remain - // available while this parent-offline composer cannot submit new input. - await scaffold.ctx.subagents.prompt({ - requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId, - parentSessionId: parent.id, - childSessionId: childId, - mode: 'continuable', - delivery: 'queue', - content: [{ type: 'text', text: REARM }], - }, new AbortController().signal) + const promptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagents/prompt') + await input.fill(REARM) + const send = page.getByRole('button', { name: 'Queue message' }) + expect(await send.isEnabled()).toBe(true) + await send.click() + expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result).toMatchObject({ ok: true }) await page.getByRole('button', { name: 'Edit queued message' }).waitFor({ timeout: 15_000 }) expect(await page.getByRole('button', { name: 'Remove queued message' }).count()).toBe(1) expect(await page.getByRole('button', { name: 'Steer queued message' }).count()).toBe(1) @@ -246,7 +237,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const aborted = waitForAbortedTurn(scaffold, childId) const interruptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagents/interruptByParent') + new URL(response.url()).pathname === '/api/subagents/interruptByUser') await stop.click() expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } @@ -256,12 +247,10 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') // Wake the parked setup message only after cancellation converges. A - // second hang keeps the parent-available case independent from this stop. + // second hang keeps the next interrupt case independent from this stop. await scaffold.ctx.subagents.prompt({ requestId: 'interrupt-ui-rearm-wake' as SubagentPromptRequestId, - parentSessionId: parent.id, childSessionId: childId, - mode: 'continuable', delivery: 'queue', content: [{ type: 'text', text: REARM_WAKE }], }, new AbortController().signal) @@ -272,9 +261,8 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co } }, 60_000) - it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => { + it('interrupts through subagents/interruptByUser, parks the follow-up, and resumes it FIFO', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow')) - // A new connection reloads the truthful parent-availability hint. const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -282,7 +270,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co await page.getByRole('navigation', { name: 'Session hierarchy' }) .getByRole('button').first().click() await page.getByRole('button', { name: /1 subagent/ }).click() - await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).press('Enter') // A live continuable child advertises the ordinary steer-all gesture, so // that placeholder is the composer's accessible name in this window. It // changes back as the queue drains, so later interactions address the @@ -343,7 +331,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co const stop = page.getByRole('button', { name: 'Stop generating' }) expect(await stop.count()).toBe(1) const interruptResponse = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/subagents/interruptByParent') + new URL(response.url()).pathname === '/api/subagents/interruptByUser') await stop.click() expect(((await (await interruptResponse).json()) as { result: { ok: boolean; value?: { accepted: boolean } } @@ -378,7 +366,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(userTexts[0]).toBe(INITIAL) - expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /) + expect(userTexts[1]).toMatch(/^Your final answer is delivered automatically if your parent is still online\./) expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, EDITED_FOLLOWUP, WAKING]) const imageMessage = events.find(event => event.type === 'user/message' && event.data.content.some(block => block.type === 'image')) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index a1b1284a19..526e115437 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -70,7 +70,7 @@ function textCompletion(text: string): object { } } -describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over the real composition', () => { +describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByUser over the real composition', () => { let scaffold: WebScaffold let sidecarRoot: string let readyFile: string @@ -113,7 +113,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th provider: 'spawn', label: 'event-sourcing researcher', signal: new AbortController().signal, - request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + prompt: [{ type: 'text', text: INITIAL }], parent, }) childId = started.childId // The hang entry writes readyFile after its prefix chunks, immediately @@ -134,9 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th const queued = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', { request: { requestId: randomUUID(), - parentSessionId: parentId, childSessionId: childId, - mode: 'continuable', delivery: 'queue', content: [{ type: 'text', text: FOLLOWUP }], }, @@ -144,10 +142,8 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th expect(queued).toMatchObject({ ok: true }) const settled = scaffold.whenTurnSettled() - const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByParent', { + const interrupted = await remote<{ accepted: true }>(scaffold, 'subagents/interruptByUser', { childSessionId: childId, - parentSessionId: parentId, - mode: 'continuable', }) expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } }) // accepted acknowledges the admitted cancel, not quiescence: wait for the @@ -169,9 +165,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th const waking = await remote<{ messageId: string }>(scaffold, 'subagents/prompt', { request: { requestId: randomUUID(), - parentSessionId: parentId, childSessionId: childId, - mode: 'continuable', delivery: 'queue', content: [{ type: 'text', text: WAKING }], }, @@ -189,7 +183,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(userTexts[0]).toBe(INITIAL) - expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /) + expect(userTexts[1]).toMatch(/^Your final answer is delivered automatically if your parent is still online\./) expect(userTexts.slice(2)).toEqual([FOLLOWUP, WAKING]) const turnEndKinds = events .filter(event => event.type === 'turn/end') diff --git a/apps/web/tests/subagent-result-notice.e2e.ts b/apps/web/tests/subagent-result-notice.e2e.ts new file mode 100644 index 0000000000..cff9482035 --- /dev/null +++ b/apps/web/tests/subagent-result-notice.e2e.ts @@ -0,0 +1,129 @@ +/** Keyless browser coverage of structured subagent outcomes and historical notice fallback. */ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type BrowserContext } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { createSettlementMessage } from '@deepseek-ai/dsh-subagent/src/continuation-messages.ts' +import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { connectFreshWorkspace, connectFreshWorkspaceZh } from './support.ts' + +const DIR = fileURLToPath(new URL('./expected/subagent-result-notice/', import.meta.url)) +const MODE = webSnapshotMode() +const OUTPUT = '## Research result\nThe original child reply stays available.' +const OUTCOMES = [ + { reason: 'completed', en: 'Subagent run completed', zh: '子智能体已完成任务' }, + { reason: 'aborted', en: 'Subagent run stopped', zh: '子智能体任务已停止' }, + { reason: 'error', en: 'Subagent run failed', zh: '子智能体执行失败' }, + { reason: 'max-tokens', en: 'Subagent reached the token limit', zh: '子智能体已达 token 上限' }, + { reason: 'refusal', en: 'Subagent declined the task', zh: '子智能体拒绝执行' }, +] as const satisfies readonly { reason: SubagentStopReason; en: string; zh: string }[] + +describe.skipIf(MODE === 'record')('web e2e: subagent result notices', () => { + let browser: Browser + beforeAll(async () => { browser = await chromium.launch() }) + afterAll(async () => { await browser?.close() }) + + it.each(['en', 'zh'] as const)('shows recorded outcomes and preserves notice bodies in %s', async (locale) => { + const scaffold = await launchWebScaffold() + let context: BrowserContext | undefined + try { + context = await browser.newContext({ + locale: locale === 'en' ? 'en-US' : 'zh-CN', timezoneId: 'Asia/Shanghai', viewport: { width: 1280, height: 900 }, + }) + const page = await context.newPage() + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await (locale === 'en' ? connectFreshWorkspace : connectFreshWorkspaceZh)(page, scaffold.workspaceCwd) + const agent = scaffold.ctx.agents.list()[0] + if (agent === undefined) throw new Error('Notice fixture has no connected Agent') + const session = agent.session + const appendTurn = (turn: number, message: ReturnType): void => { + const waking = message.source.kind !== 'user' + if (waking) session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] }) + session.append('turn/start', { turn }) + if (waking) session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', message, { surfaceOp: 'append' }) + session.append('assistant/message', { + turn, step: 1, stream: [], message: createAssistantMessage({ + content: [{ type: 'text', text: `Acknowledged ${turn}.` }], source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + appendTurn(1, createUserMessage({ content: [{ type: 'text', text: 'Inspect child execution results.' }], source: { kind: 'user' } })) + const childId = SessionId('notice-result-child') + const notices: { title: string; message: ReturnType }[] = OUTCOMES.map(outcome => ({ + title: outcome[locale], + message: createSettlementMessage(childId, { + stopReason: outcome.reason, output: [{ type: 'text', text: OUTPUT }], + }), + })) + notices.push({ + title: OUTCOMES[0][locale], + message: createSettlementMessage(childId, { stopReason: 'completed', output: [] }), + }) + notices.push({ + title: locale === 'en' ? 'Subtask status updated' : '子任务状态更新', + message: createUserMessage({ + source: { kind: 'subagent-settled', form: 'notice', senderSessionId: childId, summary: 'Background subagent finished.' }, + content: [{ type: 'text', text: 'Background subagent finished.' }], + }), + }) + for (const [index, notice] of notices.entries()) appendTurn(index + 2, notice.message) + await scaffold.ctx.sessions.flush(session) + const snapshots: string[] = [] + for (const [index, notice] of notices.entries()) { + const selector = `[data-chat-turn="${index + 2}"] [data-turn-trigger]` + const card = page.locator(selector) + await card.waitFor({ state: 'attached' }) + const header = card.locator('button').first() + await expect.poll(() => header.locator(':scope > span').nth(1).textContent()).toBe(notice.title) + await header.click() + const original = notice.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') + expect(await card.locator('[data-context-text]').textContent()).toBe(original) + snapshots.push(await captureStableAria(page, selector, scaffold.workspaceCwd)) + await header.click() + } + await page.setViewportSize({ width: 390, height: 844 }) + for (const card of await page.locator('[data-turn-trigger]').all()) { + const geometry = await card.evaluate((element) => { + const header = element.querySelector('button')! + const title = header.querySelectorAll('span')[1]! + const time = header.querySelector('time')! + return { + fits: element.scrollWidth <= element.clientWidth, + separate: title.getBoundingClientRect().right <= time.getBoundingClientRect().left, + } + }) + expect(geometry).toEqual({ fits: true, separate: true }) + } + const warningCount = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached' }) + for (const [index, notice] of notices.entries()) { + await expect.poll(() => page.locator(`[data-chat-turn="${index + 2}"] [data-turn-trigger] > button`).textContent()) + .toContain(notice.title) + } + acknowledgeReloadConnectionLoss(tripwire, warningCount) + if (MODE === 'refresh') await mkdir(DIR, { recursive: true }) + await compareOrRefreshGolden(join(DIR, `${locale}.expected.md`), snapshots.join('\n\n'), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + try { await context?.close() } finally { await scaffold.close() } + } + }) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(DIR, ['en.expected.md', 'zh.expected.md']) + }) +}) diff --git a/apps/web/tests/subagent-summary.e2e.ts b/apps/web/tests/subagent-summary.e2e.ts new file mode 100644 index 0000000000..5d3c564b09 --- /dev/null +++ b/apps/web/tests/subagent-summary.e2e.ts @@ -0,0 +1,271 @@ +/** Keyless browser coverage of subagent task summaries and durable creation receipts. */ +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Agent, AssistantStreamFrame } from '@deepseek-ai/dsh-agent' +import { + AssistantStreamAccumulator, LlmAttemptId, ToolCallId, + createAssistantMessage, createToolResultMessage, createUserMessage, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Browser, Locator, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-summary/', import.meta.url)) +const MODE = webSnapshotMode() +const DESCRIPTION = 'Review subagent task summaries' +const PROMPT = 'Inspect the subagent tool card across its lifecycle.\nCheck streaming arguments, creation receipts, and restored history.\nReturn findings without changing unrelated files.' +const SOURCE = { provider: 'deepseek-official', model: 'deepseek-v4-flash' } + +async function snapshotGolden(name: string, actual: string): Promise { + if (MODE === 'refresh') await mkdir(SNAPSHOT_DIR, { recursive: true }) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, `${name}.expected.md`), actual, MODE) +} + +function startTurn(agent: Agent, turn: number): void { + agent.session.append('turn/start', { turn }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Inspect subagent summary case ${turn}.` }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('step/start', { turn, step: 1 }) +} + +function appendCall(agent: Agent, turn: number, callId: ToolCallId, args: string, name = 'subagent'): SessionEvent<'tool/call'> { + agent.session.append('assistant/message', { + turn, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name, arguments: args }], source: SOURCE, + }), + }, { surfaceOp: 'append' }) + return agent.session.append('tool/call', { turn, step: 1, callId, name, arguments: args }) +} + +function finishCall(agent: Agent, call: SessionEvent<'tool/call'>, text: string, isError = false, meta?: SessionEvent<'tool/result'>['data']['meta']): void { + const { turn, step, callId } = call.data + agent.session.append('tool/result', { + turn, step, message: createToolResultMessage({ callId, content: [{ type: 'text', text }], isError }), + ...meta === undefined ? {} : { meta }, + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + agent.session.append('step/end', { turn, step }) + agent.session.append('turn/end', { turn, reason: { kind: 'completed' } }) +} + +function toolSelector(callId: ToolCallId): string { + return `[data-chat-call-id="${callId}"] [data-tool="subagent"]` +} + +async function expectSummary(card: Locator, text: string): Promise { + const header = card.locator('[data-disclosure-row]').first() + await expect.poll(() => header.textContent(), { timeout: 10_000 }).toBe(`Create subagent${text}`) +} + +async function expandTask(card: Locator, prompt: string): Promise { + await expandOwningTurnProcess(card.page(), card) + const toggle = card.locator('[data-expandable]').first() + if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click() + const task = card.locator('details').filter({ has: card.page().locator('summary', { hasText: /^Task$/u }) }) + await task.waitFor() + if (await task.getAttribute('open') === null) await task.locator('summary').click() + await expect.poll(() => task.locator('p').textContent()).toBe(prompt) +} + +describe.skipIf(MODE === 'record')('web e2e: subagent task summary', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + + beforeAll(async () => { + scaffold = await launchWebScaffold() + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[class*="frame"]').waitFor() + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const connected = scaffold.ctx.agents.list()[0] + if (connected === undefined) throw new Error('Subagent summary fixture has no connected Agent') + agent = connected + }) + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it('streams the description and preserves it through creation and restored history', async () => { + const callId = ToolCallId('subagent-summary-described') + const selector = toolSelector(callId) + const card = page.locator(selector) + const snapshots: string[] = [] + const capture = async (stage: string): Promise => { + await expandOwningTurnProcess(page, card) + snapshots.push(`## ${stage}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`) + } + startTurn(agent, 1) + await scaffold.ctx.sessions.flush(agent.session) + await page.locator('[data-chat-turn="1"]').getByText('Inspect subagent summary case 1.', { exact: true }).waitFor() + + const attemptId = LlmAttemptId('subagent-summary-stream') + const stream = new AssistantStreamAccumulator() + let revision = 0 + let index = 0 + const emit = (frame: AssistantStreamFrame): void => { scaffold.ctx.emit('agent/assistant-stream', { agent, frame }) } + const push = (chunk: StreamChunk): void => { + const time = Date.now() + stream.push({ time, chunk }) + emit({ type: 'chunk', attemptId, revision: ++revision, index: index++, time, chunk }) + } + emit({ type: 'start', attemptId, revision: ++revision, turn: 1, step: 1 }) + push({ type: 'block-start', index: 0, blockType: 'tool-call' }) + push({ type: 'tool-call-delta', index: 0, id: callId, name: 'subagent', argumentsDelta: `{"prompt":${JSON.stringify(PROMPT)},` }) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expect.poll(() => card.getAttribute('data-state')).toBe('preparing') + await expectSummary(card, '') + await capture('Preparing without description') + push({ type: 'tool-call-delta', index: 0, id: callId, argumentsDelta: '"description":"Review subagent' }) + await expectSummary(card, 'Review subagent') + push({ type: 'tool-call-delta', index: 0, id: callId, argumentsDelta: ' task summaries\\nAdditional description context' }) + await expectSummary(card, DESCRIPTION) + await capture('Preparing with partial description') + push({ type: 'tool-call-delta', index: 0, id: callId, argumentsDelta: '"}' }) + const args = JSON.stringify({ prompt: PROMPT, description: `${DESCRIPTION}\nAdditional description context` }) + const block = { type: 'tool-call' as const, id: callId, name: 'subagent', arguments: args } + push({ type: 'block-end', index: 0, block }) + push({ type: 'finish', reason: { kind: 'tool-calls' } }) + const message = agent.session.append('assistant/message', { + turn: 1, step: 1, stream: [...stream.snapshot()], + message: createAssistantMessage({ content: [block], source: SOURCE }), + }, { surfaceOp: 'append' }) + emit({ type: 'end', attemptId, revision: ++revision, index, outcome: { kind: 'committed', eventType: 'assistant/message', seq: message.seq } }) + const call = agent.session.append('tool/call', { turn: 1, step: 1, callId, name: 'subagent', arguments: args }) + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe('running') + await expectSummary(card, DESCRIPTION) + await capture('Running') + + finishCall(agent, call, 'Delegation accepted.', false, { kind: 'activation', subagentId: 'child-foo' }) + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe('ok') + await expectSummary(card, DESCRIPTION) + await capture('Created') + await expandTask(card, PROMPT) + await expectSummary(card, DESCRIPTION) + expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + await card.getByText('Started', { exact: true }).waitFor() + expect(await card.locator('dt').allTextContents()).toEqual(['Agent task description', 'ID']) + expect(await card.locator('dd').allTextContents()).toEqual([`${DESCRIPTION}\nAdditional description context`, 'child-foo']) + await capture('Creation receipt and complete task') + + const beforeReload = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expectSummary(card, DESCRIPTION) + await expandTask(card, PROMPT) + await expectSummary(card, DESCRIPTION) + expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + await capture('Restored creation receipt') + acknowledgeReloadConnectionLoss(tripwire, beforeReload) + await snapshotGolden('lifecycle', snapshots.join('\n\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('falls back to the prompt, keeps synchronous replies in details, and preserves errors', async () => { + const cases = [ + { id: 'fallback', args: { prompt: 'Review the fallback summary.\nKeep the complete task in details.' }, summary: 'Review the fallback summary.', result: 'started subagent fallback-worker' }, + { id: 'reply', args: { description: 'Check synchronous agent reply', prompt: 'Review the synchronous report.\nKeep every instruction available.' }, summary: 'Check synchronous agent reply', result: 'The synchronous agent found two issues.\nBoth are documented in the report.' }, + { id: 'title-only', args: {}, summary: '', result: 'started subagent empty-worker' }, + { id: 'error', args: { description: 'Delegate a failing task', prompt: 'Attempt the requested delegation.' }, summary: 'Subagent capacity exceeded', result: 'Subagent capacity exceeded\nWait for an active agent to finish.', error: true }, + ] + const snapshots: string[] = [] + for (const [index, fixture] of cases.entries()) { + const turn = index + 2 + const callId = ToolCallId(`subagent-summary-${fixture.id}`) + const selector = toolSelector(callId) + const card = page.locator(selector) + startTurn(agent, turn) + const call = appendCall(agent, turn, callId, JSON.stringify(fixture.args)) + await scaffold.ctx.sessions.flush(agent.session) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expectSummary(card, fixture.args.description ?? fixture.args.prompt?.split('\n')[0] ?? '') + finishCall(agent, call, fixture.result, fixture.error) + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe(fixture.error === true ? 'error' : 'ok') + await expectSummary(card, fixture.summary) + if (fixture.args.prompt !== undefined && fixture.error !== true) { + await expandTask(card, fixture.args.prompt) + await expectSummary(card, fixture.summary) + if (fixture.id === 'reply') { + await card.getByText('Agent response', { exact: true }).waitFor() + await card.getByText('The synchronous agent found two issues.', { exact: false }).waitFor() + await card.getByText('Both are documented in the report.', { exact: false }).waitFor() + expect(await card.getByText('Started', { exact: true }).count()).toBe(0) + } else expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + } + await expandOwningTurnProcess(page, card) + snapshots.push(`## ${fixture.id}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`) + } + await snapshotGolden('variants', snapshots.join('\n\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('presents fork creation with a distinct title, task summary, and restored receipt', async () => { + const name = 'subagent_fork' + const callId = ToolCallId('subagent-fork-summary') + const selector = `[data-chat-call-id="${callId}"] [data-tool="${name}"]` + const card = page.locator(selector) + const description = 'Review inherited conversation context' + const prompt = 'Summarize the earlier agreed requirements.\nDo not modify files.' + const snapshots: string[] = [] + const capture = async (stage: string): Promise => { + snapshots.push(`## ${stage}\n\n${await captureStableAria(page, selector, scaffold.workspaceCwd)}`) + } + const expectForkSummary = async (): Promise => { + await expect.poll(() => card.locator('[data-disclosure-row]').first().textContent()) + .toBe(`Fork subagent${description}`) + } + startTurn(agent, 6) + const call = appendCall(agent, 6, callId, JSON.stringify({ description, prompt }), name) + await scaffold.ctx.sessions.flush(agent.session) + await card.waitFor({ state: 'attached' }) + await expandOwningTurnProcess(page, card) + await expect.poll(() => card.getAttribute('data-state')).toBe('running') + await expectForkSummary() + await capture('Running') + finishCall(agent, call, 'started subagent fork-worker') + await scaffold.ctx.sessions.flush(agent.session) + await expect.poll(() => card.getAttribute('data-state')).toBe('ok') + await expandTask(card, prompt) + await expectForkSummary() + expect(await card.locator('dt').allTextContents()).toEqual(['Agent task description', 'ID']) + expect(await card.locator('dd').allTextContents()).toEqual([description, 'fork-worker']) + expect(await card.getByText('Agent response', { exact: true }).count()).toBe(0) + await capture('Creation receipt and complete task') + const beforeReload = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await card.waitFor({ state: 'attached' }) + await expandTask(card, prompt) + await expectForkSummary() + await capture('Restored creation receipt') + acknowledgeReloadConnectionLoss(tripwire, beforeReload) + await snapshotGolden('fork', snapshots.join('\n\n')) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) + + it('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['lifecycle.expected.md', 'variants.expected.md', 'fork.expected.md']) + }) +}) diff --git a/apps/web/tests/support-timezone.e2e.ts b/apps/web/tests/support-timezone.e2e.ts index 37e4de79b1..706c96a49d 100644 --- a/apps/web/tests/support-timezone.e2e.ts +++ b/apps/web/tests/support-timezone.e2e.ts @@ -1,6 +1,6 @@ import { chromium } from 'playwright' import { expect, it } from 'vitest' -import { newEnglishPage } from './support.ts' +import { newEnglishPage, pinBrowserClock, pinHostClock, WEB_FIXTURE_TIME } from './support.ts' it.each(['UTC', 'America/Los_Angeles'])('isolates the recorded browser timezone from %s', async (hostTimeZone) => { const browser = await chromium.launch({ env: { ...process.env, TZ: hostTimeZone } }) @@ -20,3 +20,40 @@ it.each(['UTC', 'America/Los_Angeles'])('isolates the recorded browser timezone await browser.close() } }) + +it('keeps browser timers and animation frames live with the Host date pinned, including reload', async () => { + const originalNow = Date.now + const hostClock = pinHostClock() + try { + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + await pinBrowserClock(page) + await page.goto('data:text/html,clock') + let previousTime = WEB_FIXTURE_TIME + for (const reload of [false, true]) { + if (reload) await page.reload() + const now = await page.evaluate(() => Date.now()) + if (reload) expect(now).toBeGreaterThan(previousTime) + else expect(now).toBeGreaterThanOrEqual(previousTime) + expect(now).toBeLessThan(WEB_FIXTURE_TIME + 60_000) + previousTime = now + await page.evaluate(() => { + setTimeout(() => { document.body.dataset.timer = 'fired' }, 0) + requestAnimationFrame(() => { document.body.dataset.frame = 'fired' }) + }) + await expect.poll(() => page.evaluate(() => ({ + timer: document.body.dataset.timer, frame: document.body.dataset.frame, + })), { timeout: 5_000 }).toEqual({ timer: 'fired', frame: 'fired' }) + } + await page.close() + const nextPage = await newEnglishPage(browser) + expect(await nextPage.evaluate(() => Math.abs(Date.now() - new Date().getTime()))).toBeLessThan(100) + } finally { + await browser.close() + } + } finally { + hostClock.mockRestore() + } + expect(Date.now).toBe(originalNow) +}) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index ad741f474d..048e3e1f6d 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -25,6 +25,36 @@ export function experimentalBundleDescription(bundle: string, locale: string): s const installationRequire = createRequire(join(REPO_ROOT, 'apps/cli/package.json')) +/** + * Check the shared warning title, theme color, and compact body alignment. + * @param dialog - visible warning dialog under test. + */ +export async function assertWarningDialogLayout(dialog: Locator): Promise { + const layout = await dialog.evaluate((element) => { + const title = element.querySelector('h2')! + const icon = title.querySelector('svg')! + const paragraph = element.querySelector('p')! + const titleRect = title.getBoundingClientRect() + const bodyRect = paragraph.getBoundingClientRect() + const color = getComputedStyle(icon).color + const previous = icon.style.color + icon.style.color = 'var(--dsw-alias-state-warn-primary)' + const warningColor = getComputedStyle(icon).color + icon.style.color = previous + return { + decorative: icon.getAttribute('aria-hidden'), + matchesWarningColor: color === warningColor, + leftGap: Math.abs(bodyRect.left - titleRect.left), + topGap: bodyRect.top - titleRect.bottom, + } + }) + expect(layout.decorative).toBe('true') + expect(layout.matchesWarningColor).toBe(true) + expect(layout.leftGap).toBeLessThan(1) + expect(layout.topGap).toBeGreaterThanOrEqual(0) + expect(layout.topGap).toBeLessThanOrEqual(16) +} + /** * The built copy of a workspace package, as the dsh installation resolves it. * The Host plugins a scaffold profile loads run from built packages through diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index e43a7c7fb8..7fbd53a759 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -220,7 +220,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await page.getByRole('button', { name: '1 subagent', exact: true }).hover() const catalog = page.getByRole('tree', { name: 'Subagent sessions' }) - await catalog.getByRole('treeitem', { name: /snapshot-flow child 1/ }).click() + await catalog.getByRole('treeitem', { name: /snapshot-flow child 1/ }).press('Enter') await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 }) expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(1) }, 60_000) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 47acf9c48c..938c0dae3c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/credential-editor.e2e.ts", "tests/sidebar-drawer.e2e.ts", "tests/creation-recommendations.e2e.ts", "tests/plugin-recommendations.e2e.ts", @@ -49,6 +50,8 @@ "tests/code-card-layout.e2e.ts", "tests/code-language.snapshot.ts", "tests/scaffold.ts", + "tests/subagent-summary.e2e.ts", + "tests/subagent-result-notice.e2e.ts", "tests/clock.e2e.ts", "tests/connection-recovery.e2e.ts", "tests/server-restart.e2e.ts", diff --git a/benchmarks/agent-continuation/child-catalog.worker.ts b/benchmarks/agent-continuation/child-catalog.worker.ts index aedb279937..34808c2d85 100644 --- a/benchmarks/agent-continuation/child-catalog.worker.ts +++ b/benchmarks/agent-continuation/child-catalog.worker.ts @@ -81,7 +81,12 @@ async function run(root: string, mode: string): Promise ctx.subagents.listChildren(PARENT_ID) + const readCatalog = async () => { + using observation = await ctx.sessionQuery.observeSession(PARENT_ID) + const catalog = observation.projections?.values.subagentCatalog + if (catalog === undefined) throw new Error('child-catalog projection is unavailable') + return catalog + } const cpuStart = process.cpuUsage() const start = performance.now() const first = await readCatalog() diff --git a/benchmarks/user-text/user-text.bench.client.ts b/benchmarks/user-text/user-text.bench.client.ts index c52a6e8c65..d5d2ce677d 100644 --- a/benchmarks/user-text/user-text.bench.client.ts +++ b/benchmarks/user-text/user-text.bench.client.ts @@ -92,6 +92,25 @@ async function worker(args: readonly string[], semiSpaceMb?: number): Pr return run.report } +/** Compare adjacent scaling samples before aggregating, so unrelated fastest rounds cannot set the ratio. */ +function doublingRatio(half: readonly number[], full: readonly number[]): number { + const ratios = half.map((duration, index) => full[index]! / duration).sort((a, b) => a - b) + return ratios[Math.floor(ratios.length / 2)]! +} + +describe('credential-matcher scaling measurement', () => { + it('compares adjacent rounds when the runner changes speed', () => { + const half = [1, 2, 2, 2, 2] + const full = [4, 4, 4, 4, 4] + expect(Math.min(...full) / Math.min(...half)).toBeGreaterThan(MAX_DOUBLING_SCALING) + expect(doublingRatio(half, full)).toBe(2) + }) + + it('rejects superlinear growth even when one round is faster', () => { + expect(doublingRatio([1, 2, 2, 2, 2], [2, 6, 6, 6, 6])).toBeGreaterThan(MAX_DOUBLING_SCALING) + }) +}) + describe('sent user text with the credential matcher', () => { let timings: ReadonlyMap let allocations: ReadonlyMap @@ -115,6 +134,11 @@ describe('sent user text with the credential matcher', () => { measures: `${benchmark.measures} Minimum of ${ATTEMPTS} per-sample means of ${measured.evaluationsPerSample} fresh evaluations after warm-up.`, }, { withSecretMs: { ms: measured.withSecretMs, cpuMs: measured.withSecretCpuMs } }, { withSecretMs: budgetMs }) } + const expectLinear = (half: UserTextWorkloadTiming, full: UserTextWorkloadTiming): void => { + const ratio = doublingRatio(half.samples.map(sample => sample.withSecretMs), full.samples.map(sample => sample.withSecretMs)) + console.log(JSON.stringify({ benchmark: `user-text/${full.id}/scaling`, ratio, budget: MAX_DOUBLING_SCALING })) + expect(ratio).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + } it.each(Object.entries(UNMATCHED_EXPECTED))('evaluates %s without credentials within its budget and ratio', (id, expected) => { const measured = timing(id) @@ -139,7 +163,7 @@ describe('sent user text with the credential matcher', () => { const half = timing('env-keys-1000') const full = timing('env-keys-2000') expect([half.secrets, full.secrets]).toEqual([1_000, 2_000]) - expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + expectLinear(half, full) }) it.each(ADVERSARIAL)('scans adversarial %s input linearly without backtracking', (name) => { @@ -148,7 +172,7 @@ describe('sent user text with the credential matcher', () => { const budgetMs = ciTimeBudget(ADVERSARIAL_EXPECTED_MS) record(benchmarkCase(`adversarial-${name}`, `One 128 KiB document repeating a ${name} unit.`), full, budgetMs) expect(full.withSecretMs).toBeLessThanOrEqual(budgetMs) - expect(full.withSecretMs / half.withSecretMs).toBeLessThanOrEqual(MAX_DOUBLING_SCALING) + expectLinear(half, full) }) it.each(workloadIds())('allocates only result nodes and masks for %s', (id) => { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 72dc6b08a6..19453e15a9 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -21,8 +21,8 @@ en: 20ef882e44b06a10 zh: c10c3934ed5570e9 /deepseek-harness-architecture/events: - en: 693407efb233f961 - zh: 37b9dd0677d4405c + en: ddebac7cfd41ec73 + zh: 51e6d0866a9e7839 /deepseek-harness-architecture/turn-flow: en: 1a79a70b62c04cdb zh: c86d765e21430405 @@ -30,8 +30,8 @@ en: b3b63d8fe7cab9fe zh: 3703fcf30b1639d0 /deepseek-harness-architecture/capability-seams: - en: a5bc20c3f2a1b370 - zh: 91773a281410b2f4 + en: 1d425c445bfcc931 + zh: 834d486f88e68edf /deepseek-harness-architecture/where-new-behavior-goes: en: ee73b1861378b099 zh: 9a182be609485f67 diff --git a/docs/architecture.md b/docs/architecture.md index e3f5cd24a3..446c50e5bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,15 +66,17 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes ## Events -Events are the extension points, and picking the right domain is the first decision in most changes. +Events provide extension points in three domains. - **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload. - **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight. - **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop. -AgentLoop initializes Agents with rollback and awaits serial `agent/created` before queued work. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering. +Subagent Activations own runtime tree membership. -The [event map](event-producer-consumer.md) lists every event's producers and consumers. +AgentLoop rolls back failed initialization and awaits serial `agent/created` before queued work; unload retains resources through teardown ([ordering](../packages/core/agent-loop/README.md#understand-the-implementation)). + +[Event producers and consumers](event-producer-consumer.md). ## Turn flow @@ -137,7 +139,7 @@ A **seam** is a swappable capability with three roles: a **Service Definition** Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product. -[Experimental Agent Teams](subsystems/agent-team.md) exposes opt-in `ctx.agentTeams` coordination with durable roster and task state plus direct inbox messaging over continuable subagents. +[Agent Teams](subsystems/agent-team.md) adds task policy and a shared task board when enabled for a root Session. Ordinary subagent controls own identity, messaging, discovery, interruption, and inbox waiting. ## Where new behavior goes diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 4ab73a419d..275e1c863d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -68,15 +68,17 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 ## 事件 -事件就是扩展点,而选对事件域是大多数改动的第一个决定。 +事件在三个域内提供扩展点。 - **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。 - **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。 - **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。 -AgentLoop 初始化 Agent,失败则回滚,并在启动已排队工作前等待串行 `agent/created`。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。 +Subagent Activation 拥有运行时树成员关系。 -[事件映射](event-producer-consumer.zh.md)列出每个事件的生产方与消费方。 +AgentLoop 回滚失败的初始化,并在启动已排队工作前等待串行 `agent/created`;卸载在 teardown 期间保留所需资源([顺序](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 + +[事件生产者与消费者](event-producer-consumer.zh.md)。 @@ -141,7 +143,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.zh.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。 -[实验性 Agent Teams](subsystems/agent-team.zh.md) 通过显式启用的 `ctx.agentTeams`,在可继续 subagent 之上提供持久 roster、任务状态和直接 inbox 消息协作。 +[Agent Teams](subsystems/agent-team.zh.md)在根 Session 开启后提供任务策略和共享任务板。普通 subagent 控制工具负责身份、通信、发现、中断和收件箱等待。 ## 新行为的归属位置 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 07016423f1..4dc23c45e6 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 45dd67234744e29d - zh: a250e73f5b159175 + en: 6215d15e0e273fa4 + zh: dec5c7779e0a42f5 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9da61dd3e7..334e3757bc 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -267,9 +267,9 @@ flowchart LR pkg_experimental_speech_to_text["experimental-speech-to-text"] svc_speechToText["ctx.speechToText
Experimental speech recognition providers"] pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] - pkg_experimental_agent_team["experimental-agent-team"] + pkg_agent_team["agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_tool_agent_team["tool-agent-team"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] svc_claudeCodeMods["ctx.claudeCodeMods
Claude Code mods bridge"] pkg_inspector["inspector"] @@ -320,6 +320,7 @@ flowchart LR pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_preset_registry --> svc_agentPresets + pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController pkg_api_remote_access --> svc_remoteAccess @@ -359,7 +360,6 @@ flowchart LR pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_api_speech_to_text --> svc_speechController pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -488,7 +488,7 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_tool_agent_team + svc_agentTeams --> pkg_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent @@ -698,7 +698,7 @@ flowchart LR | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free metadata, writes, and owner-only reads of stored keys. | | `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. | @@ -742,7 +742,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, direct peer inbox delivery, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. | +| `ctx.agentTeams` | `core` | [`agent-team`](../packages/team/agent-team) | - | [`tool-agent-team`](../packages/team/tool-agent-team) | - | Owns the shared task DAG for a subagent tree; tool-agent-team contributes task controls. | | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index f2036d67fb..d0f0bdffb0 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -269,9 +269,9 @@ flowchart LR pkg_experimental_speech_to_text["experimental-speech-to-text"] svc_speechToText["ctx.speechToText
Experimental speech recognition providers"] pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] - pkg_experimental_agent_team["experimental-agent-team"] + pkg_agent_team["agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] + pkg_tool_agent_team["tool-agent-team"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] svc_claudeCodeMods["ctx.claudeCodeMods
Claude Code mods bridge"] pkg_inspector["inspector"] @@ -322,6 +322,7 @@ flowchart LR pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_preset_registry --> svc_agentPresets + pkg_agent_team --> svc_agentTeams pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController pkg_api_remote_access --> svc_remoteAccess @@ -361,7 +362,6 @@ flowchart LR pkg_deepseek_account --> svc_deepseekAccount pkg_deepseek_account_platform --> svc_deepseekAccount pkg_deepseek_llm_api_extensions --> svc_deepseekLlmApiExtensions - pkg_experimental_agent_team --> svc_agentTeams pkg_experimental_api_speech_to_text --> svc_speechController pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse pkg_experimental_browser_use_playwright_mcp --> svc_browserUse @@ -490,7 +490,7 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_tool_agent_team + svc_agentTeams --> pkg_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent @@ -700,7 +700,7 @@ flowchart LR | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的元数据和写入操作,并仅向所有者读取已保存密钥。 | | `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 | @@ -744,7 +744,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、直接 peer inbox 投递、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 | +| `ctx.agentTeams` | `core` | [`agent-team`](../packages/team/agent-team) | - | [`tool-agent-team`](../packages/team/tool-agent-team) | - | 负责子代理树共享的任务 DAG;tool-agent-team 提供任务控制工具。 | | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh 与 PTY 发送)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a1423c4bfd..b60380b102 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -111,7 +111,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:252`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -171,6 +171,23 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-agent-team` + +- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` +- `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) + +```ts config-catalog +/** Team-service deployment limits. */ +export interface Config { + /** Maximum non-deleted tasks retained by one Team. */ + readonly maxTasks?: number +} +``` + + @@ -875,29 +892,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-agent-team` - -- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` -- `source`: [`packages/experimental/agent-team/src/types.ts:153`](../packages/experimental/agent-team/src/types.ts) - -```ts config-catalog -/** Team-service deployment limits. */ -export interface Config { - /** Maximum immutable teammate names retained by one Team. */ - readonly maxMembers?: number - /** Maximum non-deleted tasks retained by one Team. */ - readonly maxTasks?: number - /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ - readonly maxMessageBytes?: number - /** Maximum milliseconds for shared operation settlement and for each Team child drain. */ - readonly disposalTimeoutMs?: number -} -``` - - @@ -1116,10 +1110,24 @@ export interface Config { - `source`: [`packages/experimental/cua-macos/src/index.ts:18`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog -/** Native preparation, operation bounds, and profile-local application grants. */ +/** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** + * Source-build signing: `ad-hoc` (default) binds grants to the code hash; + * an object selects an existing certificate in an explicitly configured keychain. + * Installed packages and `helperPath` keep their own signature. + */ + sourceSigning: SourceSigning +} + +/** Signature selected for source builds; certificates are created and unlocked by the developer. */ +export type SourceSigning = 'ad-hoc' | { + /** Existing codesign certificate name or SHA-1 fingerprint, without a leading dash or whitespace. */ + identity: string + /** Absolute keychain path. */ + keychain: string } ``` @@ -1433,27 +1441,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -- `inject`: `agents` · `agentTeams` · `tools` · `systemPrompt` -- `source`: [`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) - -```ts config-catalog -/** Tool routing configuration. */ -export interface Config { - /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ - readonly interrupt?: boolean - /** Continuable-subagent provider used for fresh teammates. */ - readonly freshProvider?: string - /** Continuable-subagent provider used for completed-prefix fork teammates. */ - readonly forkProvider?: string -} -``` - - @@ -3572,7 +3559,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:181`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -3788,7 +3775,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3927,6 +3914,20 @@ export type TokenMeterConfig = Record ``` + + + +## `@deepseek-ai/dsh-tool-agent-team` + +- `inject`: `agentTeams` · `subagents` · `tools` · `systemPrompt` +- `source`: [`packages/team/tool-agent-team/src/index.ts:18`](../packages/team/tool-agent-team/src/index.ts) + +```ts config-catalog +/** The Team tool plugin takes no configuration. */ +export type Config = Record +``` + + @@ -4322,7 +4323,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4373,6 +4374,23 @@ export interface PresetSelectionConfig { ``` + + + +## `@deepseek-ai/dsh-tool-subagent-control` + +- `inject`: `tools` · `subagents` · `agents` · `systemPrompt` +- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:26`](../packages/subagent/tool-subagent-control/src/index.ts) + +```ts config-catalog +/** Optional subagent control tools. */ +export interface Config { + /** Register `interrupt_agent`. Defaults to true; false leaves the service API available. */ + interrupt?: boolean +} +``` + + @@ -4849,6 +4867,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | | `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-client-ui-agent-preset` | — | [`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-agent-team` | — | [`packages/client/ui-agent-team/src/index.ts`](../packages/client/ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-client-ui-approval` | — | [`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts) | | `@deepseek-ai/dsh-client-ui-attachment` | — | [`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts) | | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | @@ -4914,7 +4933,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-stats` | — | [`packages/experimental/client-ui-stats/src/index.ts`](../packages/experimental/client-ui-stats/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) | @@ -4950,7 +4968,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | | `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` · `systemPrompt` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | -| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | | `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | @@ -5006,7 +5023,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | | `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | -| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index adf1f1ef02..95af04f5dd 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -113,7 +113,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:253`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:252`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -173,6 +173,23 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-agent-team` + +- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` +- `source`: [`packages/team/agent-team/src/types.ts:132`](../packages/team/agent-team/src/types.ts) + +```ts config-catalog +/** Team-service deployment limits. */ +export interface Config { + /** Maximum non-deleted tasks retained by one Team. */ + readonly maxTasks?: number +} +``` + + @@ -877,29 +894,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-agent-team` - -- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents` -- `source`: [`packages/experimental/agent-team/src/types.ts:153`](../packages/experimental/agent-team/src/types.ts) - -```ts config-catalog -/** Team-service deployment limits. */ -export interface Config { - /** Maximum immutable teammate names retained by one Team. */ - readonly maxMembers?: number - /** Maximum non-deleted tasks retained by one Team. */ - readonly maxTasks?: number - /** Maximum UTF-8 bytes in one complete sender-framed delivery. */ - readonly maxMessageBytes?: number - /** Maximum milliseconds for shared operation settlement and for each Team child drain. */ - readonly disposalTimeoutMs?: number -} -``` - - @@ -1118,10 +1112,24 @@ export interface Config { - `source`: [`packages/experimental/cua-macos/src/index.ts:18`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog -/** Native preparation, operation bounds, and profile-local application grants. */ +/** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number + /** + * Source-build signing: `ad-hoc` (default) binds grants to the code hash; + * an object selects an existing certificate in an explicitly configured keychain. + * Installed packages and `helperPath` keep their own signature. + */ + sourceSigning: SourceSigning +} + +/** Signature selected for source builds; certificates are created and unlocked by the developer. */ +export type SourceSigning = 'ad-hoc' | { + /** Existing codesign certificate name or SHA-1 fingerprint, without a leading dash or whitespace. */ + identity: string + /** Absolute keychain path. */ + keychain: string } ``` @@ -1435,27 +1443,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -- `inject`: `agents` · `agentTeams` · `tools` · `systemPrompt` -- `source`: [`packages/experimental/tool-agent-team/src/index.ts:17`](../packages/experimental/tool-agent-team/src/index.ts) - -```ts config-catalog -/** Tool routing configuration. */ -export interface Config { - /** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */ - readonly interrupt?: boolean - /** Continuable-subagent provider used for fresh teammates. */ - readonly freshProvider?: string - /** Continuable-subagent provider used for completed-prefix fork teammates. */ - readonly forkProvider?: string -} -``` - - @@ -3574,7 +3561,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:181`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -3790,7 +3777,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:265`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3929,6 +3916,20 @@ export type TokenMeterConfig = Record ``` + + + +## `@deepseek-ai/dsh-tool-agent-team` + +- `inject`: `agentTeams` · `subagents` · `tools` · `systemPrompt` +- `source`: [`packages/team/tool-agent-team/src/index.ts:18`](../packages/team/tool-agent-team/src/index.ts) + +```ts config-catalog +/** The Team tool plugin takes no configuration. */ +export type Config = Record +``` + + @@ -4324,7 +4325,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:46`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:45`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4375,6 +4376,23 @@ export interface PresetSelectionConfig { ``` + + + +## `@deepseek-ai/dsh-tool-subagent-control` + +- `inject`: `tools` · `subagents` · `agents` · `systemPrompt` +- `source`: [`packages/subagent/tool-subagent-control/src/index.ts:26`](../packages/subagent/tool-subagent-control/src/index.ts) + +```ts config-catalog +/** Optional subagent control tools. */ +export interface Config { + /** Register `interrupt_agent`. Defaults to true; false leaves the service API available. */ + interrupt?: boolean +} +``` + + @@ -4851,6 +4869,7 @@ export interface Config { | `@deepseek-ai/dsh-client-resources` | — | [`packages/client/resources/src/index.ts`](../packages/client/resources/src/index.ts) | | `@deepseek-ai/dsh-client-ui-action-cards` | — | [`packages/experimental/client-ui-action-cards/src/index.ts`](../packages/experimental/client-ui-action-cards/src/index.ts) | | `@deepseek-ai/dsh-client-ui-agent-preset` | — | [`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts) | +| `@deepseek-ai/dsh-client-ui-agent-team` | — | [`packages/client/ui-agent-team/src/index.ts`](../packages/client/ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-client-ui-approval` | — | [`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts) | | `@deepseek-ai/dsh-client-ui-attachment` | — | [`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts) | | `@deepseek-ai/dsh-client-ui-brand-official` | — | [`packages/client/ui-brand-official/src/index.ts`](../packages/client/ui-brand-official/src/index.ts) | @@ -4916,7 +4935,6 @@ export interface Config { | `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) | | `@deepseek-ai/dsh-deliverable-file-references` | `systemPrompt` | [`packages/deliverables/deliverable-file-references/src/index.ts`](../packages/deliverables/deliverable-file-references/src/index.ts) | | `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` · `workingDirectory` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) | -| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-stats` | — | [`packages/experimental/client-ui-stats/src/index.ts`](../packages/experimental/client-ui-stats/src/index.ts) | | `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) | @@ -4952,7 +4970,6 @@ export interface Config { | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | | `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` · `systemPrompt` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | -| `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | | `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | @@ -5008,7 +5025,6 @@ export interface Config { | `@deepseek-ai/dsh-cmdline` | — | [`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts) | | `@deepseek-ai/dsh-cua-bundle` | — | [`packages/experimental/cua-bundle/src/index.ts`](../packages/experimental/cua-bundle/src/index.ts) | | `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) | -| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 682746fcee..591bcdd5ce 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: eb79b6f8ea745a9f - zh: de676c78231c823a + en: 16d67911ecaa50d3 + zh: f5259b0fbf022db3 /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 @@ -45,5 +45,5 @@ en: 85b38f78d87fc88f zh: 00d5eb625e7dfb61 /development-guide/contributor-reference/documenting-types-verbatim-ts-type-equiv: - en: 9d578b6797be921b - zh: 823cbfcfd9a75c17 + en: 6b9b948e0b80cf76 + zh: 2642f4127ed9b719 diff --git a/docs/development.md b/docs/development.md index 18130990d5..9aec6e601d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -89,7 +89,7 @@ tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. +`pnpm run build:check` runs only `tsc -p tsconfig.host.json` and `tsc -p tsconfig.client.json` concurrently. Project mode checks each aggregate's repository-wide test and script program against the package declarations already on disk and never compiles a reference, so `build:check` neither detects nor rebuilds missing or stale `build:artifacts` output; missing output surfaces as compiler errors. `pnpm run build` runs both step sets as one [scheduled](../scripts/run-gates.ts) dependency graph: each aggregate check starts once its own face's package compilation finishes and overlaps the remaining artifact steps. In all three commands `DSH_GATE_CONCURRENCY` bounds concurrent steps (`1` runs them in dependency order) and `DSH_GATE_FAIL_FAST=1` stops the remaining steps at the first failure; otherwise a failure skips only its dependents, so independent errors are all reported. A failed step prints its own output, and any failure exits non-zero without writing the client build record. Host TypeScript compiler processes receive an 8 GiB V8 old-space limit for the package graph and cold aggregate checks. Choose the command by what the change needs: @@ -218,4 +218,4 @@ The [subsystems](subsystems/README.md) pages paste source-equivalent declaration { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. Upgrade guides in version directories older than the root package version are frozen; their examples are excluded from current-API compilation. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change. diff --git a/docs/development.zh.md b/docs/development.zh.md index 47428b923b..92f0b71b99 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -93,7 +93,7 @@ tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 +`pnpm run build:check` 只并发运行 `tsc -p tsconfig.host.json` 与 `tsc -p tsconfig.client.json`。project mode 依据磁盘上已有的包声明检查各 aggregate 的仓库级测试和脚本 program,从不编译 reference,因此 `build:check` 既不检测也不重建缺失或过期的 `build:artifacts` 产物;产物缺失时表现为编译器错误。`pnpm run build` 把两组步骤作为一张[调度](../scripts/run-gates.ts)依赖图运行:每个 aggregate 检查在本 face 的包编译完成后立即开始,与其余产物步骤重叠执行。三个命令中,`DSH_GATE_CONCURRENCY` 限制并发步骤数(`1` 按依赖顺序执行),`DSH_GATE_FAIL_FAST=1` 在第一个失败时停止其余步骤;否则失败只跳过其依赖方,因此会报告所有相互独立的错误。失败步骤打印自身输出,任一失败都以非零状态退出且不写 client build record。 Host TypeScript 编译进程的 V8 old-space 上限为 8 GiB,用于包依赖图和无缓存的 aggregate 检查。 按改动需要选择命令: @@ -225,4 +225,4 @@ Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-ope { "doc": "docs/subsystems/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例的计算之外。版本目录早于根包版本的升级指南已冻结,其中的示例不参与当前 API 的编译检查。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f2f81a5362..d88ab4e38d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,18 +10,18 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -71,15 +71,15 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8cfc85b875..986d33e3eb 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,18 +12,18 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:199`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:198`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:399`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | -| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team` | +| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `browser-use-runtime`, `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`cua`](../packages/experimental/cua), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`loader-smoke`](../packages/test-support/loader-smoke), [`permission-presets`](../packages/interaction/permission-presets), `session-controller`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`creation-recommendations`](../packages/experimental/creation-recommendations), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), `liveshare`, [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:701`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:681`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -73,15 +73,15 @@ | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:166`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), `server` | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | `browser-use-runtime`, [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime` | diff --git a/docs/module-graph.md b/docs/module-graph.md index e73a0bb594..3236cc783c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -161,6 +161,7 @@ flowchart TD pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_agent_team["client-ui-agent-team"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] @@ -263,8 +264,6 @@ flowchart TD pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] - pkg_experimental_agent_team["experimental-agent-team"] - pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] pkg_experimental_auto_review["experimental-auto-review"] pkg_experimental_badge_skill_bundle["experimental-badge-skill-bundle"] @@ -273,7 +272,6 @@ flowchart TD pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"] pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] pkg_experimental_client_ui_forge["experimental-client-ui-forge"] @@ -301,7 +299,6 @@ flowchart TD pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] pkg_experimental_subagent_oar["experimental-subagent-oar"] pkg_experimental_terminal_bundle["experimental-terminal-bundle"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_tool_subagent_oar["experimental-tool-subagent-oar"] pkg_experimental_tool_worktree["experimental-tool-worktree"] pkg_experimental_translator["experimental-translator"] @@ -452,6 +449,10 @@ flowchart TD pkg_subprocess_local["subprocess-local"] pkg_win32_process["win32-process"] end + subgraph group_team["packages/team"] + pkg_agent_team["agent-team"] + pkg_tool_agent_team["tool-agent-team"] + end subgraph group_telemetry["packages/telemetry"] pkg_otel["otel"] end @@ -1329,6 +1330,7 @@ flowchart TD pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_system_prompt pkg_tool_subagent_control --> pkg_tools pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model @@ -1395,12 +1397,6 @@ flowchart TD pkg_session_reference --> pkg_subagent pkg_session_reference --> pkg_system_prompt pkg_session_reference --> pkg_typert_protocol - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_session_projection - pkg_experimental_agent_team --> pkg_subagent pkg_experimental_subagent_oar --> pkg_llm pkg_experimental_subagent_oar --> pkg_session pkg_experimental_subagent_oar --> pkg_subagent @@ -1425,6 +1421,13 @@ flowchart TD pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent pkg_sdk_protocol --> pkg_user_questions + pkg_agent_team --> pkg_agent + pkg_agent_team --> pkg_llm + pkg_agent_team --> pkg_session + pkg_agent_team --> pkg_session_persistence + pkg_agent_team --> pkg_session_projection + pkg_agent_team --> pkg_subagent + pkg_agent_team --> pkg_typert_protocol pkg_workflow_ptc --> pkg_agent pkg_workflow_ptc --> pkg_llm pkg_workflow_ptc --> pkg_ptc_runtime @@ -1435,11 +1438,6 @@ flowchart TD pkg_workflow_ptc --> pkg_tools pkg_workflow_ptc --> pkg_workflow pkg_workflow_ptc --> pkg_working_directory - pkg_experimental_tool_agent_team --> pkg_agent - pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_session - pkg_experimental_tool_agent_team --> pkg_system_prompt - pkg_experimental_tool_agent_team --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_api_session_controller pkg_schedule --> pkg_brand @@ -1456,6 +1454,12 @@ flowchart TD pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session + pkg_tool_agent_team --> pkg_agent + pkg_tool_agent_team --> pkg_agent_team + pkg_tool_agent_team --> pkg_session + pkg_tool_agent_team --> pkg_subagent + pkg_tool_agent_team --> pkg_system_prompt + pkg_tool_agent_team --> pkg_tools pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller @@ -1549,6 +1553,7 @@ flowchart TD | [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | +| [`client-ui-agent-team`](../packages/client/ui-agent-team) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | @@ -1614,9 +1619,7 @@ flowchart TD | [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | -| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | | [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | @@ -1858,22 +1861,22 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`experimental-subagent-oar`](../packages/experimental/subagent-oar) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| [`agent-team`](../packages/team/agent-team) | `team` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | -| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`tool-agent-team`](../packages/team/tool-agent-team) | `team` | [`agent`](../packages/core/agent), [`agent-team`](../packages/team/agent-team), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 818130b1fe..8ce9f2337b 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -163,6 +163,7 @@ flowchart TD pkg_client_shortcuts["client-shortcuts"] pkg_client_store["client-store"] pkg_client_ui_agent_preset["client-ui-agent-preset"] + pkg_client_ui_agent_team["client-ui-agent-team"] pkg_client_ui_approval["client-ui-approval"] pkg_client_ui_attachment["client-ui-attachment"] pkg_client_ui_brand_official["client-ui-brand-official"] @@ -265,8 +266,6 @@ flowchart TD pkg_creation_recommendations["creation-recommendations"] pkg_cua["cua"] pkg_cua_bundle["cua-bundle"] - pkg_experimental_agent_team["experimental-agent-team"] - pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_api_speech_to_text["experimental-api-speech-to-text"] pkg_experimental_auto_review["experimental-auto-review"] pkg_experimental_badge_skill_bundle["experimental-badge-skill-bundle"] @@ -275,7 +274,6 @@ flowchart TD pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"] pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"] pkg_experimental_claude_code_mods["experimental-claude-code-mods"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] pkg_experimental_client_ui_forge["experimental-client-ui-forge"] @@ -303,7 +301,6 @@ flowchart TD pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"] pkg_experimental_subagent_oar["experimental-subagent-oar"] pkg_experimental_terminal_bundle["experimental-terminal-bundle"] - pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_tool_subagent_oar["experimental-tool-subagent-oar"] pkg_experimental_tool_worktree["experimental-tool-worktree"] pkg_experimental_translator["experimental-translator"] @@ -454,6 +451,10 @@ flowchart TD pkg_subprocess_local["subprocess-local"] pkg_win32_process["win32-process"] end + subgraph group_team["packages/team"] + pkg_agent_team["agent-team"] + pkg_tool_agent_team["tool-agent-team"] + end subgraph group_telemetry["packages/telemetry"] pkg_otel["otel"] end @@ -1331,6 +1332,7 @@ flowchart TD pkg_tool_subagent_control --> pkg_llm pkg_tool_subagent_control --> pkg_session pkg_tool_subagent_control --> pkg_subagent + pkg_tool_subagent_control --> pkg_system_prompt pkg_tool_subagent_control --> pkg_tools pkg_api_session_controller --> pkg_agent pkg_api_session_controller --> pkg_agent_default_model @@ -1397,12 +1399,6 @@ flowchart TD pkg_session_reference --> pkg_subagent pkg_session_reference --> pkg_system_prompt pkg_session_reference --> pkg_typert_protocol - pkg_experimental_agent_team --> pkg_agent - pkg_experimental_agent_team --> pkg_llm - pkg_experimental_agent_team --> pkg_session - pkg_experimental_agent_team --> pkg_session_persistence - pkg_experimental_agent_team --> pkg_session_projection - pkg_experimental_agent_team --> pkg_subagent pkg_experimental_subagent_oar --> pkg_llm pkg_experimental_subagent_oar --> pkg_session pkg_experimental_subagent_oar --> pkg_subagent @@ -1427,6 +1423,13 @@ flowchart TD pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent pkg_sdk_protocol --> pkg_user_questions + pkg_agent_team --> pkg_agent + pkg_agent_team --> pkg_llm + pkg_agent_team --> pkg_session + pkg_agent_team --> pkg_session_persistence + pkg_agent_team --> pkg_session_projection + pkg_agent_team --> pkg_subagent + pkg_agent_team --> pkg_typert_protocol pkg_workflow_ptc --> pkg_agent pkg_workflow_ptc --> pkg_llm pkg_workflow_ptc --> pkg_ptc_runtime @@ -1437,11 +1440,6 @@ flowchart TD pkg_workflow_ptc --> pkg_tools pkg_workflow_ptc --> pkg_workflow pkg_workflow_ptc --> pkg_working_directory - pkg_experimental_tool_agent_team --> pkg_agent - pkg_experimental_tool_agent_team --> pkg_experimental_agent_team - pkg_experimental_tool_agent_team --> pkg_session - pkg_experimental_tool_agent_team --> pkg_system_prompt - pkg_experimental_tool_agent_team --> pkg_tools pkg_schedule --> pkg_agent pkg_schedule --> pkg_api_session_controller pkg_schedule --> pkg_brand @@ -1458,6 +1456,12 @@ flowchart TD pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session + pkg_tool_agent_team --> pkg_agent + pkg_tool_agent_team --> pkg_agent_team + pkg_tool_agent_team --> pkg_session + pkg_tool_agent_team --> pkg_subagent + pkg_tool_agent_team --> pkg_system_prompt + pkg_tool_agent_team --> pkg_tools pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller @@ -1551,6 +1555,7 @@ flowchart TD | [`client-shortcuts`](../packages/client/shortcuts) | `client` | — | | [`client-store`](../packages/client/store) | `client` | — | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — | +| [`client-ui-agent-team`](../packages/client/ui-agent-team) | `client` | — | | [`client-ui-approval`](../packages/client/ui-approval) | `client` | — | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — | | [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | — | @@ -1616,9 +1621,7 @@ flowchart TD | [`client-ui-creation-recommendations`](../packages/experimental/client-ui-creation-recommendations) | `experimental` | — | | [`client-ui-plugin-recommendations`](../packages/experimental/client-ui-plugin-recommendations) | `experimental` | — | | [`cua-bundle`](../packages/experimental/cua-bundle) | `experimental` | — | -| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-badge-skill-bundle`](../packages/experimental/badge-skill-bundle) | `experimental` | — | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | — | | [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | — | | [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | @@ -1860,22 +1863,22 @@ flowchart TD | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | +| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`experimental-subagent-oar`](../packages/experimental/subagent-oar) | `experimental` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-tool-subagent-oar`](../packages/experimental/tool-subagent-oar) | `experimental` | [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | `experimental` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/experimental/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-ralph`](../packages/experimental/tool-ralph) | `experimental` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | +| [`agent-team`](../packages/team/agent-team) | `team` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | -| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | +| [`tool-agent-team`](../packages/team/tool-agent-team) | `team` | [`agent`](../packages/core/agent), [`agent-team`](../packages/team/agent-team), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 53c7d45680..c12b02a77d 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: 31c6dab0878b05a8 - zh: 176d0f7cbdc3fce3 + en: d334560286daab57 + zh: ddece2494dd2131c /session-persistence-event-catalog/event-envelope: en: 56414ffd00cdc32a zh: 6dc11eb013e95e41 @@ -27,8 +27,8 @@ en: 49891ca9be5ac123 zh: 49891ca9be5ac123 /session-persistence-event-catalog/events/agent-preset/agent-preset-selected-log-only: - en: 9eb4e8ec6b503d83 - zh: 6d96288a30d92b81 + en: 50a6b116a29bc6b0 + zh: 7fce128060557296 /session-persistence-event-catalog/events/approval: en: 9d2ec4c4c3664b9f zh: 9d2ec4c4c3664b9f @@ -225,17 +225,20 @@ en: a640800a38b3c874 zh: a640800a38b3c874 /session-persistence-event-catalog/events/team/team-member-log-only: - en: a2fe5b8582ef5f46 - zh: 42bfffcde3f3d886 + en: de16dcf2046b402c + zh: b6c73eb866413523 /session-persistence-event-catalog/events/team/team-message-delivered-log-only: - en: 4a716b14e33eab18 - zh: dcde26e4dc40b62e + en: 9c9d9238de0eeb89 + zh: 1f52e5da217dae3d /session-persistence-event-catalog/events/team/team-message-queued-log-only: - en: 05bf873244099da5 - zh: 8a0b62912087f4aa + en: 184b700f0a0363ae + zh: 74b7939bf1bface1 +/session-persistence-event-catalog/events/team/team-mode-log-only: + en: 9481b9f0697707e4 + zh: 83aa2d33782a7b00 /session-persistence-event-catalog/events/team/team-task-log-only: - en: 5ffeb64225feda2a - zh: d58129e189a726fb + en: 16a961e45e7ff021 + zh: c61e51809823dc17 /session-persistence-event-catalog/events/todo: en: 308d4bb4b6b96e35 zh: 308d4bb4b6b96e35 @@ -684,8 +687,11 @@ en: 49351fa0be662888 zh: 49351fa0be662888 /session-persistence-event-catalog/resolved-persistence-types/recall: - en: 5c5a7a7e6517bdf0 - zh: 5c5a7a7e6517bdf0 + en: c04491605decd774 + zh: c04491605decd774 +/session-persistence-event-catalog/resolved-persistence-types/refusal: + en: e55aa9d6983aecde + zh: e55aa9d6983aecde /session-persistence-event-catalog/resolved-persistence-types/rejected: en: b982810b4d04978d zh: b982810b4d04978d @@ -816,8 +822,11 @@ en: 6795a23aa9bc8d2e zh: 6795a23aa9bc8d2e /session-persistence-event-catalog/resolved-persistence-types/team-message-queued: - en: d683235390472024 - zh: d683235390472024 + en: efe4678972a1a64e + zh: efe4678972a1a64e +/session-persistence-event-catalog/resolved-persistence-types/team-mode: + en: 91a387e367e07f73 + zh: 91a387e367e07f73 /session-persistence-event-catalog/resolved-persistence-types/team-task: en: 7aa9286372341561 zh: 7aa9286372341561 @@ -1011,11 +1020,11 @@ en: a6a2e38fdd6d127a zh: 1fe82356d89f5769 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: fd75d1f4b9bd643c - zh: dde892ea9752f298 + en: 054dbd6367e1403d + zh: 5b071d10ae1ec245 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: c6171fd8144d74b1 - zh: 2f3dd34cc8d7ed92 + en: 58bc64eb0f7fb589 + zh: 8cc8279eb5786ef5 /session-persistence-event-catalog/resolved-persistence-types/epochheader: en: 3bdc8ecd0bdf0c9c zh: 0fe5b75c174ebab4 @@ -1125,11 +1134,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: 06b510217b6056c4 - zh: 5b3a1e022f96de57 + en: a3754a186db0e1bb + zh: e9467b50a27889d5 /session-persistence-event-catalog/resolved-persistence-types/message: - en: 7a8ec632289bc7f9 - zh: 5c90cb4f3d5d0a67 + en: 0be581a4095c3493 + zh: 2c815426f3d3717a /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1140,14 +1149,14 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 65fb012fcc0fa7db - zh: b829c586714e45e2 + en: 702995c31653ab91 + zh: cf60875749663bd3 /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: ddeed90cb6112496 - zh: cefb41765c45b22f + en: 1ec5fd7b1b8050a9 + zh: ee4a3f61be48cd17 /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 8e96551640079569 - zh: a06d587638d42237 + en: 9e25f2f1513f7b15 + zh: 2723b6aa3d8b723e /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 5f9154ca34235ab9 zh: a833105910ebfa82 @@ -1206,11 +1215,11 @@ en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: 1f9f610fa0e05abf - zh: 11560710a1b871de + en: a537c3568f3f07db + zh: 20c97f3c3fd82e8b /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: 465fe144e60dd62f - zh: 29e1029426106c3b + en: efa083dfdcf04208 + zh: eed525395ff9f2e8 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1236,11 +1245,11 @@ en: 725539dcd5c335ed zh: 5ca08d3667f84ff9 /session-persistence-event-catalog/resolved-persistence-types/subagentdescriptordata: - en: c56821e57987ae9e - zh: 0364f59a472d51da + en: 2b9c68682eec6f78 + zh: 8ef5e64326c1eae6 /session-persistence-event-catalog/resolved-persistence-types/subagentsettledmessagesource: - en: d34acab659e74957 - zh: 25cabc6c9fb44a7e + en: e6d9113dc09f4098 + zh: 3426e4f2f3cc5612 /session-persistence-event-catalog/resolved-persistence-types/surfaceop: en: 737806b6ec54263d zh: b4e44293950ddf8c @@ -1248,26 +1257,26 @@ en: 94876079d6696df9 zh: d25c7ad9f5054eb7 /session-persistence-event-catalog/resolved-persistence-types/systempromptmessagesource: - en: a3eabd0e9fc565dd - zh: fa763aefdbc1bdb8 + en: 967c82dcda03d0d4 + zh: ad590611be701d97 /session-persistence-event-catalog/resolved-persistence-types/teammemberphase: - en: 2bf8f06fe328ab33 - zh: ded602f18e0deadf + en: bf78ae1a14d64469 + zh: 0d5c34aff23a133c /session-persistence-event-catalog/resolved-persistence-types/teammembersnapshot: - en: 8a37845006830548 - zh: b8e7caf6189b92df + en: 1815ad87650025e6 + zh: d2a0dceabcc2090e /session-persistence-event-catalog/resolved-persistence-types/teammessagesnapshot: - en: 9b94faac43ba068f - zh: a83582be6a6a3c1a + en: d7ba2fe5b24c90cb + zh: b26f7e1ef0eccce0 /session-persistence-event-catalog/resolved-persistence-types/teammessagesource: - en: 269f0a4fcbea1c0c - zh: 5f2c50c03c951995 + en: 2ca6c43fc056fd37 + zh: 3d5b5f2917f326fc /session-persistence-event-catalog/resolved-persistence-types/teamtasksnapshot: - en: cfa8230ef5d661b2 - zh: cb40ce86d6778f09 + en: 0c6aac89e3dcb25c + zh: d772017667ffc2a1 /session-persistence-event-catalog/resolved-persistence-types/teamtaskstatus: - en: 9bff57038f472770 - zh: 4eec0e793ae16d53 + en: b6bd8e9986773217 + zh: ed177c2f090964af /session-persistence-event-catalog/resolved-persistence-types/textblock: en: 7ad0f4915397b511 zh: bcaa8bd449927989 @@ -1320,14 +1329,14 @@ en: 778debd61e0284a2 zh: 2cc82ad9e80f18b1 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: 953127f7cf1e0866 - zh: 3613ef1aafd8191a + en: bcd450bc3ade2a1a + zh: e442be61e4391383 /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: a15a08beb2b824da - zh: 4fd8dc6b9680c4cf + en: 6f29a4e108379f9a + zh: 2bf0f494b8fcd2b2 /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: 85e537b82f1da7d8 - zh: 73a1b9d4b5d29ab0 + en: e4fc10ce549b51ad + zh: cb29ffcb8003b355 /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1350,11 +1359,11 @@ en: 0223d7ffb67ac272 zh: c2a8221caab6d350 /session-persistence-event-catalog/resolved-persistence-types/number~2: - en: 2786fef560851bb4 - zh: 5d787475356b2606 + en: 4147b170f52814af + zh: e78991ac1c41ebec /session-persistence-event-catalog/resolved-persistence-types/string: - en: 0179cc082ac8637c - zh: e740ade45db5c263 + en: caa52cd38cec3aee + zh: 3ff0f1fceaac2eb1 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1392,8 +1401,11 @@ en: 9e296094df7462ba zh: e7e3255a3ada9279 /session-persistence-event-catalog/resolved-persistence-types/union-3-variants~4: - en: 5fc89a7ec1cecd78 - zh: dc66aa8e4aae1847 + en: 4ac5570e2b5181be + zh: 447832dc281d0caf +/session-persistence-event-catalog/resolved-persistence-types/union-5-variants: + en: f60d7d997e33f12d + zh: ec049a1c1ab9f8b3 /session-persistence-event-catalog/resolved-persistence-types/union-6-variants: en: ca99faddb359b6bb zh: 5c7ac9b445d9e5be @@ -1410,8 +1422,8 @@ en: c861888d6239b700 zh: a9b2271d3c6b8737 /session-persistence-event-catalog/resolved-persistence-types/agentpreset: - en: 1afbe8196f161601 - zh: a320a36a8928817c + en: 1282a0b3d5fa8d4d + zh: aa533aea643dbefb /session-persistence-event-catalog/resolved-persistence-types/allowedmodels: en: 6178321110ceb69d zh: 0a9d94cab9d57487 @@ -1491,8 +1503,11 @@ en: 9748b4f0facfe9f8 zh: 83ae2cd2cd02b804 /session-persistence-event-catalog/resolved-persistence-types/displayname-providerid-subjectid-tenantid: - en: d2c98ed9b98325ae - zh: bf8c2f7967cf9cc8 + en: c6fbe4268a4430db + zh: 8fec9d6def3c775c +/session-persistence-event-catalog/resolved-persistence-types/enabled: + en: b8b8f28b5bfcd153 + zh: 602d970c3c090522 /session-persistence-event-catalog/resolved-persistence-types/end-start: en: 4b7a8793663d3fef zh: 249459c825d330c3 @@ -1512,11 +1527,11 @@ en: 4cb1c48095df98dc zh: 5a03e44761af559e /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 7d1610d20e987988 - zh: 1297cba0f2a2c73d + en: c21561adcf9398a5 + zh: fac8b21392750aae /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 4f9635604ed5b84d - zh: 74810a2d049907e6 + en: 081d257a87d8fb2d + zh: 49bdba3da8d5f7e4 /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1533,11 +1548,11 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: 8b5c65ce7b57514f - zh: 231494d71e5d635a + en: 7c2c00c4635970ca + zh: 19818a2c43b4c57d /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 5a3be677a8dc51ff - zh: c5fc8b80f73e72d9 + en: 67b7eda689a3a115 + zh: 5ffdf39f3ee061b2 /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: en: 8e86cd838e3e4a49 zh: f5fec2a8adf1f3cc @@ -1905,17 +1920,17 @@ en: 3216b23c131d0070 zh: 73ca171c6014f16e /session-persistence-event-catalog/resolved-persistence-types/member-teamid-version: - en: 82b0989dad6bbfc0 - zh: 0789b63f1d59b2ed + en: 815fc4d70ba0375b + zh: ae9f6d2a163ff62b /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: en: 86fe91fe83eeb08a zh: 2ebf2f1904829753 /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: - en: f778769acb264dd5 - zh: eba9b81d04e9f57b + en: edb364403e9e9f84 + zh: d1298ec2e67a0eeb /session-persistence-event-catalog/resolved-persistence-types/messageid-targetid-teamid-version: - en: 91a948ede28ebcb5 - zh: 8a2b0ac6bb68a09e + en: 42a1e343b5e87854 + zh: ae777d3cd40835a6 /session-persistence-event-catalog/resolved-persistence-types/mode-source: en: 3028b222670ce5ad zh: cae4c9437e560d61 @@ -1947,8 +1962,8 @@ en: 5360af614fe753e3 zh: 96427c489cc71de6 /session-persistence-event-catalog/resolved-persistence-types/task-teamid-version: - en: a2fcd46e54b14120 - zh: d9a6e0e50bbff9ad + en: 4952b12d354b8aaf + zh: b8c2ef51b7ab082e /session-persistence-event-catalog/resolved-persistence-types/todos: en: 8ab873d30e062fd5 zh: a138832c6e299ebb @@ -1956,11 +1971,11 @@ en: 374b8adee8d571fa zh: b21057efc58e6490 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: da6e084997b20cdc - zh: 1780f6fcf3496de1 + en: 2ce26699bb57cf69 + zh: a5fc9ea510a3d458 /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: 1a79ed1a97145a14 - zh: d6f658a85c1e65f7 + en: ae0b65d2268c758b + zh: 8244a6d65cfb01f3 /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -2004,11 +2019,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: b9c35ef9a9e1d585 - zh: 7b8ad008c8b7389c + en: f5c7b6c1d3610861 + zh: 2baec8a404127ce8 /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: b0a3c504326f42f9 - zh: f13a1c1ca28f7da3 + en: fe3390ea4e6769e3 + zh: 5a25cbdf990ef85f /session-persistence-event-catalog/resolved-persistence-types/type-disabled: en: 585627a50ece4e5f zh: 157837aee3da0ac3 @@ -2082,11 +2097,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: 5a42c1482248e385 - zh: c7ba7d071e4756de + en: d26d1cd97d87f4a6 + zh: 992f1290578da1f4 /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: d88fd740ff167f04 - zh: fe6598a1bf4cd9bf + en: 211b5793ff00bed6 + zh: e8382b4f9adb08fd /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2112,8 +2127,11 @@ en: 2a00c9dbe70dcd8e zh: 96573157cd613c91 /session-persistence-event-catalog/resolved-persistence-types/type-team-message-queued: - en: 3baf69fe52f2d751 - zh: 185be1b973fac77e + en: 735e6acfb6180948 + zh: de7626c523ae57a2 +/session-persistence-event-catalog/resolved-persistence-types/type-team-mode: + en: 0b50dd6b99450d8e + zh: a9f8d2b85e2092af /session-persistence-event-catalog/resolved-persistence-types/type-team-task: en: 872aad5b748d70d5 zh: 396f986a703b76fd @@ -2163,11 +2181,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: a1d9300dd7a7b043 - zh: 3cb1e0e6834e9765 + en: 1f43a7944fa86bcf + zh: faa883c372923440 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: 44035d460a9d926b - zh: 8006b6dbfc58d5ae + en: 28e1108b02b392dc + zh: 0dde507cef7d790e /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: 99c8837344178a47 zh: bf746f5678346596 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 94f5b932b6..62b9f4617e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e) | +| `event:agent/inbox/spliced` | event | `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | [`{ type: "developer/message" }`](#persistence-type-sha256-70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f) | +| `event:developer/message` | event | `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | [`{ type: "developer/message" }`](#persistence-type-sha256-70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c) | +| `event:session/title-llm-request` | event | `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -68,6 +68,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:team/member` | event | `4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc` | [`{ type: "team/member" }`](#persistence-type-sha256-4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc) | | `event:team/message/delivered` | event | `48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb` | [`{ type: "team/message/delivered" }`](#persistence-type-sha256-48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb) | | `event:team/message/queued` | event | `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | [`{ type: "team/message/queued" }`](#persistence-type-sha256-21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d) | +| `event:team/mode` | event | `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | [`{ type: "team/mode" }`](#persistence-type-sha256-920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75) | | `event:team/task` | event | `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` | [`{ type: "team/task" }`](#persistence-type-sha256-d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a) | | `event:todo/write` | event | `b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26` | [`{ type: "todo/write" }`](#persistence-type-sha256-b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26) | | `event:tool-workflow/agent-end` | event | `babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7` | [`{ type: "tool-workflow/agent-end" }`](#persistence-type-sha256-babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7) | @@ -80,7 +81,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | [`{ type: "user/message" }`](#persistence-type-sha256-5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451) | +| `event:user/message` | event | `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | [`{ type: "user/message" }`](#persistence-type-sha256-dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -198,15 +199,14 @@ Source: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/type * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. In a child Session, an occurrence - * before its first own `subagent/descriptor` marks an independent spawn - * selection that cold resume restores instead of joining the parent's - * composition (`dsh-subagent` README, "Restoring child composition"). + * the header's creation-time value. Subagent restoration reads this saved + * selection independently of its creator (`dsh-subagent` README, + * "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -Source: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +Source: [`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -1009,13 +1009,13 @@ Source: [`packages/core/session/src/types.ts:338`](../packages/core/session/src/ #### `team/member` — log-only ```ts persistence-catalog -/** Whole teammate lifecycle value, stored only in the Team Lead Session. */ +/** Historical teammate lifecycle value; new teammates are continuable entries in the Lead's subagent catalog. */ 'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } ``` Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) @@ -1033,7 +1033,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) @@ -1046,7 +1046,18 @@ Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) + + + +#### `team/mode` — log-only + +```ts persistence-catalog +/** User-selected Team mode; child Agents use the selection of their runtime root. */ +'team/mode': { enabled: boolean } +``` + +Source: [`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) @@ -1059,7 +1070,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -2355,6 +2366,14 @@ SHA-256: `383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f` `"recall"` + + +### `"refusal"` + +SHA-256: `4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa` + +`"refusal"` + ### `"rejected"` @@ -2707,6 +2726,14 @@ SHA-256: `4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159` `"team/message/queued"` + + +### `"team/mode"` + +SHA-256: `d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5` + +`"team/mode"` + ### `"team/task"` @@ -3383,7 +3410,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | required | `string` | - + @@ -3391,7 +3418,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` +SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3400,7 +3427,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4099,7 +4126,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4107,15 +4134,15 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36` +SHA-256: `e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) -- [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) +- [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) +- [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4192,7 +4219,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4200,7 +4227,7 @@ One of: ### `MessageSource` -SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` +SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4279,7 +4306,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `session-reference` | `"recall"` | `references`: [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }[]`](#persistence-type-sha256-df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18); `version`: `1` | [`SessionReferenceSource`](#persistence-type-sha256-f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f) | | `skill-catalog` | `"catalog"` | `entries`: [`{ description, name }[]`](#persistence-type-sha256-aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba) | [`SkillCatalogSource`](#persistence-type-sha256-65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099) | | `skill-invocation` | `"instructions"` | `name`: `string` | [`SkillInvocationSource`](#persistence-type-sha256-672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db) | -| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a) | +| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936) | | `system-prompt` | not declared | none | [`SystemPromptMessageSource`](#persistence-type-sha256-65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad) | | `team-message` | not declared | `messageId`: `string`; `senderId`: `string`; `senderName`: `string`; `teamId`: `string` | [`TeamMessageSource`](#persistence-type-sha256-2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0) | | `time-context` | `"catalog"` | none | [`{ kind: "time-context", form: "catalog" }`](#persistence-type-sha256-85fe96bf15d5c7559eec3bae4d6cf1a76fda9f5be05a29c2830cff560cab0c0d) | @@ -4325,13 +4352,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72` +SHA-256: `95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70` -Array of [`Message`](#persistence-type-sha256-c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36). +Array of [`Message`](#persistence-type-sha256-e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39). @@ -4699,7 +4726,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4707,7 +4734,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` +SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) @@ -4715,7 +4742,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4884,7 +4911,7 @@ One of: - [`ContinuableSubagentDescriptorData`](#persistence-type-sha256-6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f) - [`OneShotSubagentDescriptorData`](#persistence-type-sha256-5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564) - + @@ -4892,7 +4919,7 @@ One of: ### `SubagentSettledMessageSource` -SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` +SHA-256: `848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936` Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packages/subagent/subagent/src/continuation-messages.ts) @@ -4901,6 +4928,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa | `form` | required | `"notice"` | | `kind` | required | `"subagent-settled"` | | `senderSessionId` | required | `string` | +| `stopReason` | optional | [`union (5 variants)`](#persistence-type-sha256-866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0) | | `summary` | required | `string` | @@ -4957,7 +4985,7 @@ Sources: [`packages/llm/llm/src/message.ts:35`](../packages/llm/llm/src/message. - + @@ -4965,7 +4993,7 @@ Sources: [`packages/llm/llm/src/message.ts:35`](../packages/llm/llm/src/message. SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` -Sources: [`packages/experimental/agent-team/src/types.ts:45`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:47`](../packages/team/agent-team/src/types.ts) One of: @@ -4975,7 +5003,7 @@ One of: - + @@ -4983,7 +5011,7 @@ One of: SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` -Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:50`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4997,7 +5025,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experi - + @@ -5005,7 +5033,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5017,7 +5045,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/exper - + @@ -5025,7 +5053,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5037,7 +5065,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/exper - + @@ -5045,7 +5073,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/exper SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` -Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5060,7 +5088,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experi - + @@ -5068,7 +5096,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experi SHA-256: `b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943` -Sources: [`packages/experimental/agent-team/src/types.ts:72`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:61`](../packages/team/agent-team/src/types.ts) One of: @@ -5390,7 +5418,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5398,7 +5426,7 @@ Sources: [`packages/core/session/src/types.ts:238`](../packages/core/session/src ### `UserMessage` -SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` +SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5407,19 +5435,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3` +SHA-256: `690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0` -Array of [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce). +Array of [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1). @@ -5537,12 +5565,6 @@ Array of `number`. - - - - - - @@ -5575,6 +5597,12 @@ Array of `number`. + + + + + + @@ -5611,7 +5639,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5755,6 +5783,20 @@ One of: - `"in_progress"` - `"pending"` + + +### `union (5 variants)` + +SHA-256: `866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0` + +One of: + +- `"aborted"` +- `"completed"` +- `"error"` +- `"max-tokens"` +- `"refusal"` + ### `union (6 variants)` @@ -5820,7 +5862,7 @@ Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/ SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -Sources: [`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +Sources: [`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) | Property | Presence | Type | |---|---|---| @@ -6234,6 +6276,18 @@ Sources: [`packages/experimental/liveshare/src/host/viewer-principal.ts:29`](../ | `subjectId` | required | `string` | | `tenantId` | required | `string` | + + +### `{ enabled }` + +SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` + +Sources: [`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `enabled` | required | `boolean` | + ### `{ end, start }` @@ -6330,18 +6384,18 @@ Sources: [`packages/core/session/src/types.ts:398`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a` +SHA-256: `65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e` Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6425,17 +6479,17 @@ Sources: [`packages/core/session/src/types.ts:435`](../packages/core/session/src |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401` +SHA-256: `e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51` Sources: [`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -7858,7 +7912,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7886,7 +7940,7 @@ Sources: [`packages/core/session/src/types.ts:338`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7900,7 +7954,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8046,7 +8100,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8094,17 +8148,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` +SHA-256: `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8345,17 +8399,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` +SHA-256: `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8755,17 +8809,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` +SHA-256: `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8917,6 +8971,22 @@ SHA-256: `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | `time` | required | `number` | | `type` | required | `"team/message/queued"` | + + + + +### `{ type: "team/mode" }` + +SHA-256: `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` + +| Property | Presence | Type | +|---|---|---| +| `data` | required | [`{ enabled }`](#persistence-type-sha256-c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434) | +| `ignorable` | optional | `true` | +| `seq` | required | `number` | +| `time` | required | `number` | +| `type` | required | `"team/mode"` | + @@ -9188,17 +9258,17 @@ Sources: [`packages/llm/llm/src/types.ts:502`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` +SHA-256: `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4a9ff92ca4..e29977fd3f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e) | +| `event:agent/inbox/spliced` | event | `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` | [`{ type: "developer/message" }`](#persistence-type-sha256-70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f) | +| `event:developer/message` | event | `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | [`{ type: "developer/message" }`](#persistence-type-sha256-70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c) | +| `event:session/title-llm-request` | event | `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -70,6 +70,7 @@ | `event:team/member` | event | `4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc` | [`{ type: "team/member" }`](#persistence-type-sha256-4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc) | | `event:team/message/delivered` | event | `48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb` | [`{ type: "team/message/delivered" }`](#persistence-type-sha256-48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb) | | `event:team/message/queued` | event | `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | [`{ type: "team/message/queued" }`](#persistence-type-sha256-21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d) | +| `event:team/mode` | event | `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` | [`{ type: "team/mode" }`](#persistence-type-sha256-920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75) | | `event:team/task` | event | `d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a` | [`{ type: "team/task" }`](#persistence-type-sha256-d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a) | | `event:todo/write` | event | `b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26` | [`{ type: "todo/write" }`](#persistence-type-sha256-b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26) | | `event:tool-workflow/agent-end` | event | `babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7` | [`{ type: "tool-workflow/agent-end" }`](#persistence-type-sha256-babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7) | @@ -82,7 +83,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` | [`{ type: "user/message" }`](#persistence-type-sha256-5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451) | +| `event:user/message` | event | `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | [`{ type: "user/message" }`](#persistence-type-sha256-dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450) | | `event:web/deepseek-search-llm-request` | event | `0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -200,15 +201,14 @@ export type SessionEvent = { * The session's agent preset was chosen after creation, while the session * was still blank. Log-only: it records the composition later turns ran * under, so a resumed or forked session rebuilds the same one instead of - * the header's creation-time value. In a child Session, an occurrence - * before its first own `subagent/descriptor` marks an independent spawn - * selection that cold resume restores instead of joining the parent's - * composition (`dsh-subagent` README, "Restoring child composition"). + * the header's creation-time value. Subagent restoration reads this saved + * selection independently of its creator (`dsh-subagent` README, + * "Restoring child composition"). */ 'agent-preset/selected': { agentPreset: string } ``` -来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) ### `approval/*` @@ -1011,13 +1011,13 @@ export type SessionEvent = { #### `team/member` — log-only ```ts persistence-catalog -/** Whole teammate lifecycle value, stored only in the Team Lead Session. */ +/** Historical teammate lifecycle value; new teammates are continuable entries in the Lead's subagent catalog. */ 'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot } ``` 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) @@ -1035,7 +1035,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) @@ -1048,7 +1048,18 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) + + + +#### `team/mode` — log-only + +```ts persistence-catalog +/** User-selected Team mode; child Agents use the selection of their runtime root. */ +'team/mode': { enabled: boolean } +``` + +来源:[`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) @@ -1061,7 +1072,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) ### `todo/*` @@ -2357,6 +2368,14 @@ SHA-256: `383cf73ae4748e5beb281ae74857ba1ffb92ae01075b9fbf51af78b666007a8f` `"recall"` + + +### `"refusal"` + +SHA-256: `4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa` + +`"refusal"` + ### `"rejected"` @@ -2709,6 +2728,14 @@ SHA-256: `4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159` `"team/message/queued"` + + +### `"team/mode"` + +SHA-256: `d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5` + +`"team/mode"` + ### `"team/task"` @@ -3385,7 +3412,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` | `body` | 必需 | [`{ max_tokens, messages, model, thinking?, … }`](#persistence-type-sha256-9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee) | | `endpoint` | 必需 | `string` | - + @@ -3393,7 +3420,7 @@ SHA-256: `abd6ca62a5ab166553848324a23fef90eed7f59c8021bd63e7ea5bc6fd666b0c` ### `DeveloperMessage` -SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` +SHA-256: `01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3402,7 +3429,7 @@ SHA-256: `64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -4101,7 +4128,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4109,15 +4136,15 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36` +SHA-256: `e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) -- [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) +- [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) +- [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) - [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) @@ -4194,7 +4221,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4202,7 +4229,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` +SHA-256: `b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4281,7 +4308,7 @@ SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` | `session-reference` | `"recall"` | `references`: [`{ capturedFormatVersion?, capturedThroughSeq, compacted, inputIndex, … }[]`](#persistence-type-sha256-df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18); `version`: `1` | [`SessionReferenceSource`](#persistence-type-sha256-f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f) | | `skill-catalog` | `"catalog"` | `entries`: [`{ description, name }[]`](#persistence-type-sha256-aba175fb20ffc71112c55efe70458ff5d7dbfb83501892b4ba8d077f0ea5d7ba) | [`SkillCatalogSource`](#persistence-type-sha256-65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099) | | `skill-invocation` | `"instructions"` | `name`: `string` | [`SkillInvocationSource`](#persistence-type-sha256-672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db) | -| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a) | +| `subagent-settled` | `"notice"` | `senderSessionId`: `string`; `summary`: `string` | [`SubagentSettledMessageSource`](#persistence-type-sha256-848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936) | | `system-prompt` | 未声明 | 无 | [`SystemPromptMessageSource`](#persistence-type-sha256-65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad) | | `team-message` | 未声明 | `messageId`: `string`; `senderId`: `string`; `senderName`: `string`; `teamId`: `string` | [`TeamMessageSource`](#persistence-type-sha256-2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0) | | `time-context` | `"catalog"` | 无 | [`{ kind: "time-context", form: "catalog" }`](#persistence-type-sha256-85fe96bf15d5c7559eec3bae4d6cf1a76fda9f5be05a29c2830cff560cab0c0d) | @@ -4327,13 +4354,13 @@ SHA-256: `7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72` +SHA-256: `95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70` -[`Message`](#persistence-type-sha256-c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36) 的数组。 +[`Message`](#persistence-type-sha256-e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39) 的数组。 @@ -4701,7 +4728,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4709,7 +4736,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` +SHA-256: `601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25` 来源:[`packages/session/session-title-llm/src/index.ts:43`](../packages/session/session-title-llm/src/index.ts) @@ -4717,7 +4744,7 @@ SHA-256: `7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4886,7 +4913,7 @@ SHA-256: `86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde` - [`ContinuableSubagentDescriptorData`](#persistence-type-sha256-6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f) - [`OneShotSubagentDescriptorData`](#persistence-type-sha256-5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564) - + @@ -4894,7 +4921,7 @@ SHA-256: `86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde` ### `SubagentSettledMessageSource` -SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` +SHA-256: `848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936` 来源:[`packages/subagent/subagent/src/continuation-messages.ts:29`](../packages/subagent/subagent/src/continuation-messages.ts) @@ -4903,6 +4930,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` | `form` | 必需 | `"notice"` | | `kind` | 必需 | `"subagent-settled"` | | `senderSessionId` | 必需 | `string` | +| `stopReason` | 可选 | [`union (5 variants)`](#persistence-type-sha256-866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0) | | `summary` | 必需 | `string` | @@ -4959,7 +4987,7 @@ SHA-256: `65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad` - + @@ -4967,7 +4995,7 @@ SHA-256: `65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad` SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` -来源:[`packages/experimental/agent-team/src/types.ts:45`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:47`](../packages/team/agent-team/src/types.ts) 以下类型之一: @@ -4977,7 +5005,7 @@ SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` - + @@ -4985,7 +5013,7 @@ SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd` SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` -来源:[`packages/experimental/agent-team/src/types.ts:48`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:50`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4999,7 +5027,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` - + @@ -5007,7 +5035,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:129`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:108`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5019,7 +5047,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` - + @@ -5027,7 +5055,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:138`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:117`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5039,7 +5067,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` - + @@ -5047,7 +5075,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` -来源:[`packages/experimental/agent-team/src/types.ts:75`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:64`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5062,7 +5090,7 @@ SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` - + @@ -5070,7 +5098,7 @@ SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357` SHA-256: `b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943` -来源:[`packages/experimental/agent-team/src/types.ts:72`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:61`](../packages/team/agent-team/src/types.ts) 以下类型之一: @@ -5392,7 +5420,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5400,7 +5428,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` +SHA-256: `613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5409,19 +5437,19 @@ SHA-256: `4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`action-cards`, `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3` +SHA-256: `690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0` -[`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) 的数组。 +[`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) 的数组。 @@ -5539,12 +5567,6 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` - - - - - - @@ -5577,6 +5599,12 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` + + + + + + @@ -5613,7 +5641,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:389`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/team/agent-team/src/types.ts:20`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:32`](../packages/team/agent-team/src/types.ts) · [`packages/team/agent-team/src/types.ts:8`](../packages/team/agent-team/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5757,6 +5785,20 @@ SHA-256: `fd0d91a1f9dd2efb8959bdf5e07f8f0a98b1cdfa38ce8d4732ebde0b44c3abd4` - `"in_progress"` - `"pending"` + + +### `union (5 variants)` + +SHA-256: `866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0` + +以下类型之一: + +- `"aborted"` +- `"completed"` +- `"error"` +- `"max-tokens"` +- `"refusal"` + ### `union (6 variants)` @@ -5822,7 +5864,7 @@ SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` SHA-256: `6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406` -来源:[`packages/preset/agent-preset-registry/src/session.ts:31`](../packages/preset/agent-preset-registry/src/session.ts) +来源:[`packages/preset/agent-preset-registry/src/session.ts:30`](../packages/preset/agent-preset-registry/src/session.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6236,6 +6278,18 @@ SHA-256: `0a92c5bdfa06c2e2615b777728f3aa95f408d43b9012e7b754568311251db623` | `subjectId` | 必需 | `string` | | `tenantId` | 必需 | `string` | + + +### `{ enabled }` + +SHA-256: `c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434` + +来源:[`packages/team/agent-team/src/types.ts:171`](../packages/team/agent-team/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `enabled` | 必需 | `boolean` | + ### `{ end, start }` @@ -6332,18 +6386,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a` +SHA-256: `65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e` 来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6427,17 +6481,17 @@ SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401` +SHA-256: `e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51` 来源:[`packages/core/agent/src/types.ts:110`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -7860,7 +7914,7 @@ SHA-256: `9c001ecb5e1d3c3894410db2a1e383842e5f8454fb52182338686158abc1b3ee` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:173`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7888,7 +7942,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:177`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7902,7 +7956,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:179`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8048,7 +8102,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/team/agent-team/src/types.ts:175`](../packages/team/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8096,17 +8150,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e` +SHA-256: `ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8347,17 +8401,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f` +SHA-256: `70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8757,17 +8811,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c` +SHA-256: `d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8919,6 +8973,22 @@ SHA-256: `21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d` | `time` | 必需 | `number` | | `type` | 必需 | `"team/message/queued"` | + + + + +### `{ type: "team/mode" }` + +SHA-256: `920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `data` | 必需 | [`{ enabled }`](#persistence-type-sha256-c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434) | +| `ignorable` | 可选 | `true` | +| `seq` | 必需 | `number` | +| `time` | 必需 | `number` | +| `type` | 必需 | `"team/mode"` | + @@ -9190,17 +9260,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451` +SHA-256: `dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.i18n.yaml b/docs/persistence-changes/2026-10-19-session-team-mode.i18n.yaml new file mode 100644 index 0000000000..d69a435040 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-19-session-team-mode.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-19-session-team-mode.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-19-session-team-mode: + en: a882cc1bc383bf80 + zh: 33f40913b70ad97b +/2026-10-19-session-team-mode/summary: + en: 665465829d68c487 + zh: 2d5bd37335c79aad +/2026-10-19-session-team-mode/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-19-session-team-mode/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-19-session-team-mode/compatibility: + en: c0ca565e19ad32a7 + zh: 548c334b7e15b464 +/2026-10-19-session-team-mode/verification: + en: 7f3a3e4b1ffb9f85 + zh: dd9258af01f40937 +/2026-10-19-session-team-mode/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.md b/docs/persistence-changes/2026-10-19-session-team-mode.md new file mode 100644 index 0000000000..ca350836a8 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-19-session-team-mode + +English | [中文](2026-10-19-session-team-mode.zh.md) + +## Summary + +Records the root Session’s Agent Teams selection with the required team/mode event. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-19-session-team-mode +baseline: false +changes: + - root: "event:team/mode" + previous: null + after: "920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75" + decision: same-version +``` + + +## Compatibility + +Existing V4 logs remain readable. Without a selection, historical tasks enable Team; without historical tasks it remains disabled. Readers predating team/mode refuse the unknown required discriminator, so they cannot silently restore the wrong mode. Ordinary forks ignore inherited mode selections and start disabled. No existing payload or Session header version changes. + + +## Verification + +197 focused Team, tool, model-menu and client tests passed; the Web mode and task-board scenario passed 8 tests. The authored team-session-mode headless and TypeScript SDK snapshots exercise on/off commands; Python SDK raw-event projection retains both selections. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.schema.json b/docs/persistence-changes/2026-10-19-session-team-mode.schema.json new file mode 100644 index 0000000000..4e854f51d3 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.schema.json @@ -0,0 +1,15 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:team/mode","kind":"event","event":"team/mode","surface":false,"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75"} + ], + "types": [], + "nodes": { + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "920d87e22b7409bc": {"kind":"object","properties":[{"name":"data","type":"c2a13242efc3bea4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d8a476d2c6fd30df","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "c2a13242efc3bea4": {"kind":"object","properties":[{"name":"enabled","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "d8a476d2c6fd30df": {"kind":"literal","value":"team/mode"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true} + } +} diff --git a/docs/persistence-changes/2026-10-19-session-team-mode.zh.md b/docs/persistence-changes/2026-10-19-session-team-mode.zh.md new file mode 100644 index 0000000000..6ea99093a6 --- /dev/null +++ b/docs/persistence-changes/2026-10-19-session-team-mode.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-19-session-team-mode + +[English](2026-10-19-session-team-mode.md) | 中文 + +## 概述 + +通过必读的 team/mode 事件记录根 Session 的智能体团队选择。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-19-session-team-mode +baseline: false +changes: + - root: "event:team/mode" + previous: null + after: "920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75" + decision: same-version +``` + + +## 兼容性 + +已有 V4 日志仍可读取。没有显式选择时,历史任务使 Team 开启;没有历史任务时保持关闭。旧读取器拒绝未知的必读 team/mode 判别值,避免静默恢复错误模式。普通分叉忽略继承的模式选择,初始保持关闭。不修改已有载荷或 Session 头部版本。 + + +## 验证 + +197 个 Team、工具、模型菜单和客户端定向测试通过;Web 开关与任务板场景的 8 个测试通过。team-session-mode 的 headless 和 TypeScript SDK 人工编写快照覆盖开关命令;Python SDK 原始事件投影保留两次选择。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.i18n.yaml b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.i18n.yaml new file mode 100644 index 0000000000..e1d1916155 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-27-subagent-settlement-attribution.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-27-subagent-settlement-attribution: + en: d2a2da526a11227c + zh: 047e8cc6da20118f +/2026-10-27-subagent-settlement-attribution/summary: + en: fb422315a6c3e9e7 + zh: bce866c511613c73 +/2026-10-27-subagent-settlement-attribution/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-27-subagent-settlement-attribution/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-27-subagent-settlement-attribution/compatibility: + en: e18e26f07dcc315e + zh: 7120bc3f3e9b90b5 +/2026-10-27-subagent-settlement-attribution/verification: + en: 42d56d2378276e50 + zh: 16c96c5c3d018964 +/2026-10-27-subagent-settlement-attribution/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md new file mode 100644 index 0000000000..0f5994a749 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-27-subagent-settlement-attribution + +English | [中文](2026-10-27-subagent-settlement-attribution.zh.md) + +## Summary + +Adds optional stopReason attribution to persisted subagent settlement notices while keeping the Session format version unchanged. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-27-subagent-settlement-attribution +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca" + decision: same-version + - root: "event:user/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450" + decision: same-version +``` + + +## Compatibility + +New readers accept historical notices without stopReason and must not infer it from the English summary. Writers always record the exact captured execution stopReason. Older readers preserve additional source metadata and continue using the existing kind, notice form, summary, senderSessionId, and unchanged content; native V4 source admission validates the producer kind rather than rejecting additional attribution fields. The new metadata affects presentation only, not replay, authority, or recovery. No migration or Session version bump is required. + + +## Verification + +Focused Vitest runs passed: continuation-messages.spec.ts (14 tests), external-activation.spec.ts (19 tests), continuation-messages-adapter.spec.ts (1 test), and continuation.spec.ts (166 tests), all under packages/subagent/subagent/tests. Command: node node_modules/vitest/vitest.mjs run followed by the listed owner paths. Coverage includes all five terminal reasons, historical attribution without inference, external cleanup failure retaining the captured execution reason, cold resume, and unchanged DeepSeek Messages wire content. Historical Session generations remain unchanged; affected current-format snapshot expectations include stopReason. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.schema.json b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.schema.json new file mode 100644 index 0000000000..3299e5d126 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.schema.json @@ -0,0 +1,259 @@ +{ + "formatVersion": 2, + "roots": [ + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450"} + ], + "types": [], + "nodes": { + "01c161379a3c3394": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "02b2c23f2d94b028": {"kind":"object","properties":[{"name":"kind","type":"c5687b1313206a6f","optional":false}],"indices":[]}, + "05205074b24d8340": {"kind":"literal","value":"action-cards"}, + "0657d3ca5ac4e7bf": {"kind":"primitive","type":"number"}, + "07e1a58c58b593f5": {"kind":"object","properties":[{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "09655b34d7f3cf1e": {"kind":"literal","value":"system-prompt"}, + "09f3f8dc417f2782": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"8af841fc9b265ade","optional":false},{"name":"source","type":"65a8400e451e667e","optional":false}],"indices":[]}, + "0a92c5bdfa06c2e2": {"kind":"object","properties":[{"name":"displayName","type":"4cd48f3e5108bd6e","optional":true},{"name":"providerId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subjectId","type":"4cd48f3e5108bd6e","optional":false},{"name":"tenantId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "0ed8d1108fe7a5e1": {"kind":"object","properties":[{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "0eeb17e1df89b1eb": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "0f46153645c29784": {"kind":"object","properties":[{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "0fe52387f6505270": {"kind":"literal","value":"plan-mode"}, + "11638dc58a31496f": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "12d5808fac1996b9": {"kind":"object","properties":[{"name":"attachment","type":"b0b0eb1b16490f6b","optional":false},{"name":"offloaded","type":"dacf3d5f3e4ff619","optional":true},{"name":"type","type":"fb8b6a2e53cb9cf4","optional":false}],"indices":[]}, + "1379f0b72275994d": {"kind":"literal","value":"developer/message"}, + "16d0507cb94b4cfc": {"kind":"literal","value":"relay"}, + "1922cd79a70806c1": {"kind":"literal","value":"skill-invocation"}, + "1938c8c20f1cba7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1e0b0b66b6f0c53f": {"kind":"literal","value":"aborted"}, + "1e709346005826eb": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "1ebc6f9ff3aa9e3a": {"kind":"literal","value":"completed"}, + "1f0133217eeeffd8": {"kind":"literal","value":"tool-call"}, + "1f1385dbc3e875c0": {"kind":"literal","value":"repeat-tool-reminder"}, + "1fdad624334d3548": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "2152caf1a71e588e": {"kind":"object","properties":[{"name":"kind","type":"2e4debca12f2c449","optional":false},{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "2235d957bf4b7c6c": {"kind":"object","properties":[{"name":"kind","type":"7b1ce60193fa8b69","optional":false}],"indices":[]}, + "224aa11d4d5f88c4": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "23af3759678a30de": {"kind":"literal","value":"dsh-session-title-llm"}, + "24b10f7cdae64928": {"kind":"literal","value":"runtime-context"}, + "24ce31aa528272e5": {"kind":"object","properties":[{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "260523b412c19ad8": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "2765e9119edf5816": {"kind":"literal","value":"model"}, + "277b61f9c6f81704": {"kind":"literal","value":"file"}, + "28936c614bd4e7e2": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "290f32bfd81ff85a": {"kind":"object","properties":[{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "297fbd8067d7815a": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "2aad1179f9576314": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "2ab12a0e345658f9": {"kind":"array","element":"83da72c6857601c7"}, + "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, + "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, + "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, + "312aee2f0c42aabb": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "31907de956375182": {"kind":"object","properties":[{"name":"compactionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"6b23ed9ce1b397e6","optional":false},{"name":"sourceCommandId","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, + "3247e566f4340029": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "335e242de1fcc17b": {"kind":"union","types":["bcf0caf62d964b2f","e438a9d4fb883b3e"]}, + "383cf73ae4748e5b": {"kind":"literal","value":"recall"}, + "3b04790e08b35241": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "3b4f2c9ef2c1217e": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "3bd652ebfa8726b3": {"kind":"union","types":["0657d3ca5ac4e7bf","938f7c7357bc403e"]}, + "3bdb8dc6989655b0": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "3c2535ca0a2062f1": {"kind":"literal","value":"reasoning"}, + "3e2ece4967cfebe9": {"kind":"literal","value":"next-step"}, + "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, + "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "43e311235aa537df": {"kind":"object","properties":[{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"83d6916021eede8f","optional":false}],"indices":[]}, + "45f005e6bbb76452": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a561c111a78d452": {"kind":"literal","value":"refusal"}, + "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4b259f7f8f0a44ad": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"isError","type":"aa93010d0e521ad7","optional":true},{"name":"role","type":"a13d8f379222ece8","optional":false},{"name":"source","type":"d9c462b9f62fdd3e","optional":false},{"name":"toolCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4cd48f3e5108bd6e": {"kind":"primitive","type":"string"}, + "4d60dddaa65d3da8": {"kind":"object","properties":[{"name":"actor","type":"0a92c5bdfa06c2e2","optional":false},{"name":"kind","type":"6ba7edb6447b6a3e","optional":false},{"name":"shareId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "4e44c9118152f682": {"kind":"literal","value":"text"}, + "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, + "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, + "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, + "52c6113cb0e53f34": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "58015da1c1950a66": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "587bb11da730e06a": {"kind":"literal","value":"session/title-llm-request"}, + "589d0d91239681f8": {"kind":"literal","value":"developer"}, + "58f94390b982f8a5": {"kind":"literal","value":"image/png"}, + "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "5cdbf342b7748d15": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "5d03ba38734809bc": {"kind":"array","element":"0657d3ca5ac4e7bf"}, + "5d7a64ed6aac5551": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "601327bedca5f98a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"95b5cf90ea1f57da","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "613660455704fb82": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, + "65377a76c6a58f78": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"01c161379a3c3394","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, + "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, + "672c4aa2a9b736e3": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1922cd79a70806c1","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"}, + "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "690583582246babf": {"kind":"array","element":"613660455704fb82"}, + "6b23ed9ce1b397e6": {"kind":"literal","value":"compact-checkpoint"}, + "6ba7edb6447b6a3e": {"kind":"literal","value":"share-viewer"}, + "6ccb6bc423fb3952": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "6ea608bdc8a7a63d": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "6ebcc2dbec9f2dab": {"kind":"literal","value":"skill-catalog"}, + "6f22a9d02b9a1f11": {"kind":"object","properties":[{"name":"baseline","type":"dacf3d5f3e4ff619","optional":true},{"name":"baselineIdentity","type":"4cd48f3e5108bd6e","optional":true},{"name":"changes","type":"2ab12a0e345658f9","optional":false},{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"fbd2c74b346a1595","optional":false}],"indices":[]}, + "6f44f32be9bcd9a8": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, + "70ea8e7943c5e2d7": {"kind":"object","properties":[{"name":"data","type":"65377a76c6a58f78","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, + "7382c4fc95fe8c5d": {"kind":"literal","value":"remove"}, + "7554565be416b034": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "756b5e23a5cb2c7f": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "78a9be340090b735": {"kind":"literal","value":"tmux-context"}, + "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, + "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, + "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, + "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, + "814d9434e1ddf907": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"3c2535ca0a2062f1","optional":false}],"indices":[]}, + "82470020b5bfcb1a": {"kind":"literal","value":"agent-message"}, + "824a4aad44a54e24": {"kind":"union","types":["4d60dddaa65d3da8","deebfc7c62177ea8"]}, + "825ca23416b1ea9d": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "829a2fb3be6efd4b": {"kind":"literal","value":"agent/inbox/spliced"}, + "83079c8a3f733ac3": {"kind":"object","properties":[{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8338ee303e2bd448": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, + "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "848639f8787f035b": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"866f7b2dbdc5b421","optional":true},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "85d6d96ff8842974": {"kind":"literal","value":"answered"}, + "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, + "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "866f7b2dbdc5b421": {"kind":"union","types":["1e0b0b66b6f0c53f","1ebc6f9ff3aa9e3a","4a561c111a78d452","68141cbe3994a5ff","a2be0f0cb59f0d4a"]}, + "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "87b1a33564b9a4d6": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "88841202942c7683": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8a046b52331959aa": {"kind":"object","properties":[{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "8af841fc9b265ade": {"kind":"literal","value":"system"}, + "8c1c4f72755a78ea": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "8c627d7a45fa406c": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "8e611bf46821e500": {"kind":"object","properties":[{"name":"tool","type":"fcf427290c6213c2","optional":true},{"name":"toolName","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"85f9b505b7bdff1f","optional":false}],"indices":[]}, + "8f324cea3916c051": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, + "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "938f7c7357bc403e": {"kind":"primitive","type":"null"}, + "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95b5cf90ea1f57da": {"kind":"array","element":"e272493e3ea0b33b"}, + "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, + "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, + "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "96f4a9c81fc0f940": {"kind":"union","types":["3e2ece4967cfebe9","62ecb62dc0fe1b5e"]}, + "99135d8a39379779": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "99e7e7d7fed55ae6": {"kind":"literal","value":"tool-jobs"}, + "99f0e98184709ad1": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "9ac52f0f84fec2eb": {"kind":"literal","value":"model-selection"}, + "9edc162949abf93b": {"kind":"object","properties":[{"name":"deliveryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"db6c5246911d47a7","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"ruleId","type":"4cd48f3e5108bd6e","optional":false},{"name":"source","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "9f65c43e340bba31": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "a13d8f379222ece8": {"kind":"literal","value":"tool"}, + "a15a19ef63cf5d1b": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "a16e567c8cd2c55c": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "a2066bd52f61218b": {"kind":"object","properties":[{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, + "a2be0f0cb59f0d4a": {"kind":"literal","value":"error"}, + "a3c6f1ea38e9a61d": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a4afec59b75aad5d": {"kind":"object","properties":[{"name":"clientTimeZone","type":"4cd48f3e5108bd6e","optional":true},{"name":"kind","type":"68f44f59e91c4139","optional":false},{"name":"principal","type":"824a4aad44a54e24","optional":true},{"name":"rpcId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "a720757f704a2512": {"kind":"literal","value":"tool-registry"}, + "a9059a81430b3207": {"kind":"literal","value":"hooks-claude-code"}, + "aa2bf9e82b9fdeb9": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, + "aa93010d0e521ad7": {"kind":"primitive","type":"boolean"}, + "aae343bc3d663c83": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "aba175fb20ffc711": {"kind":"array","element":"83079c8a3f733ac3"}, + "ada310bf0bdb8fed": {"kind":"object","properties":[{"name":"kind","type":"68f44f59e91c4139","optional":false}],"indices":[]}, + "ade057f4fdc387ae": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "aec150797b655c5f": {"kind":"literal","value":"catalog"}, + "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b120362fd216a16a": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, + "b2a5c8c008a31e3d": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, + "b3681c026a0b98e9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "b4ec0af39fd52ed9": {"kind":"object","properties":[{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b52f215a6cd69105": {"kind":"array","element":"8064c03c87c357fd"}, + "b613f83572fe2fbf": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b6f94c17dd9bbd7c": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false}],"indices":[]}, + "b70f3e81fe11b7a5": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false}],"indices":[]}, + "b765ee31033cea46": {"kind":"literal","value":"set"}, + "b79641a9d25e7566": {"kind":"object","properties":[{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "b815d7b91cdd9fc6": {"kind":"literal","value":"goal"}, + "ba2a1a8f194b1bb2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "ba4c40b4381e0dcd": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "baccc97ec34be6c4": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "bb94f3217aa56c88": {"kind":"literal","value":"hooks-codex"}, + "bbc5696e3f13c758": {"kind":"opaque","reason":"unknown"}, + "bcf0caf62d964b2f": {"kind":"object","properties":[{"name":"endSeq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"op","type":"f6cb462f808309e3","optional":false},{"name":"startSeq","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "bf16d2e98a7f1389": {"kind":"literal","value":"subagent-settled"}, + "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, + "c5228b7a55498d84": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "c55827fba7018198": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"9645cf93769a6dfb","optional":false},{"name":"outcome","type":"85d6d96ff8842974","optional":false}],"indices":[]}, + "c5687b1313206a6f": {"kind":"literal","value":"cordis-host-runner"}, + "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, + "cb1c8959e1726f82": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, + "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, + "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d95c624eddecdacf": {"kind":"object","properties":[{"name":"data","type":"601327bedca5f98a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, + "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, + "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, + "dacf3d5f3e4ff619": {"kind":"literal","value":true}, + "db06ef35b27a3842": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "db6c5246911d47a7": {"kind":"literal","value":"webhook"}, + "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, + "dcb07e04496586d2": {"kind":"object","properties":[{"name":"data","type":"613660455704fb82","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, + "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "ddce54a3873dc571": {"kind":"object","properties":[{"name":"data","type":"e1880c67e735e228","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, + "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "de25352d2dc41963": {"kind":"literal","value":"user/message"}, + "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, + "deaae5cd30987d4f": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "deebfc7c62177ea8": {"kind":"object","properties":[{"name":"kind","type":"95a3ffccbf3c8afc","optional":false}],"indices":[]}, + "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, + "e1880c67e735e228": {"kind":"object","properties":[{"name":"inserted","type":"690583582246babf","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "e272493e3ea0b33b": {"kind":"union","types":["01c161379a3c3394","09f3f8dc417f2782","4b259f7f8f0a44ad","613660455704fb82","fa87537f4a9c1e25"]}, + "e438a9d4fb883b3e": {"kind":"literal","value":"append"}, + "e7f4cbb3a57f4cbd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "e8de0f457fed5b9f": {"kind":"literal","value":"notice"}, + "ebb768d85ec87fb6": {"kind":"literal","value":"image/webp"}, + "edbbfff1128099fd": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"a9059a81430b3207","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "ee577a0cc18664c9": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f19bee84bf3d2090": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "f230c0b583e23b25": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "f29d63336e8266d8": {"kind":"object","properties":[{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, + "f2da0f27c1e6add5": {"kind":"object","properties":[{"name":"attachment","type":"f19bee84bf3d2090","optional":false},{"name":"type","type":"277b61f9c6f81704","optional":false}],"indices":[]}, + "f4a66f654996c093": {"kind":"object","properties":[{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f56c5b59ca016131": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"7f13860ee89a2d35","optional":false},{"name":"references","type":"df843efd87c78cf8","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, + "f6329fb30c7ea801": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, + "f6605d6c57933aea": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f68c335306535a00": {"kind":"literal","value":"user-approval"}, + "f6afe41e3926f422": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, + "f6cb462f808309e3": {"kind":"literal","value":"replace"}, + "f7283223aacdc39b": {"kind":"object","properties":[{"name":"kind","type":"9ac52f0f84fec2eb","optional":false}],"indices":[]}, + "f88f1b7b6bb733f6": {"kind":"literal","value":1}, + "f97f47b0eb87905e": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fa87537f4a9c1e25": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"95c0e88fcf0bafb2","optional":false},{"name":"source","type":"719c51f67074f970","optional":false}],"indices":[]}, + "fb8b6a2e53cb9cf4": {"kind":"literal","value":"image"}, + "fbd2c74b346a1595": {"kind":"literal","value":"agent-instructions"}, + "fcf427290c6213c2": {"kind":"primitive","type":"never"}, + "fcf81d3c58c13593": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, + "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} + } +} diff --git a/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.zh.md b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.zh.md new file mode 100644 index 0000000000..4ea60a4c94 --- /dev/null +++ b/docs/persistence-changes/2026-10-27-subagent-settlement-attribution.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-27-subagent-settlement-attribution + +[English](2026-10-27-subagent-settlement-attribution.md) | 中文 + +## 概述 + +为持久化的子代理结算通知增加可选 stopReason 归属信息,保持 Session 格式版本不变。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-27-subagent-settlement-attribution +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca" + decision: same-version + - root: "event:user/message" + previous: "2026-10-13-liveshare-share-viewer-principal" + after: "dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450" + decision: same-version +``` + + +## 兼容性 + +新读取方接受缺少 stopReason 的历史通知,不得从英文摘要推断该字段。写入方始终记录已捕获执行的确切 stopReason。旧读取方保留额外的 source 元数据,继续使用已有 kind、notice form、summary、senderSessionId 和未改变的内容;原生 V4 source 准入校验生产方 kind,不拒绝额外归属字段。新元数据只影响展示,不影响回放、权限或恢复。无需迁移或提升 Session 版本。 + + +## 验证 + +定向 Vitest 运行通过:continuation-messages.spec.ts(14 个测试)、external-activation.spec.ts(19 个测试)、continuation-messages-adapter.spec.ts(1 个测试)和 continuation.spec.ts(166 个测试),均位于 packages/subagent/subagent/tests。命令为 node node_modules/vitest/vitest.mjs run,后接上述测试文件路径。覆盖全部五种终止原因、历史归属信息不作推断、外部清理失败保留已捕获的执行原因、冷恢复,以及不变的 DeepSeek Messages 线协议内容。历史 Session 代际保持不变;受影响的当前格式快照预期包含 stopReason。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index e9e8287e17..fcd95ef300 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 5912b107ada0bfc4 - zh: b997083d6fed3f07 + en: a660985fd2941e51 + zh: 0d4aed6be93be922 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index e0a594594d..24b42751fa 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 626 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 66 / 631 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 4202dfcc4e..ef48b880a4 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 626 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 66 / 631 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3563fb4cf5..05ac9941ce 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -5,7 +5,7 @@ {"key":"JsonlHeaderLine","kind":"header","digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f"}, {"key":"SessionEventEnvelope","kind":"envelope","digest":"5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc"}, {"key":"event:agent-preset/selected","kind":"event","event":"agent-preset/selected","surface":false,"digest":"a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b"}, - {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e"}, + {"key":"event:agent/inbox/spliced","kind":"event","event":"agent/inbox/spliced","surface":false,"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482"}, {"key":"event:approval/asked","kind":"event","event":"approval/asked","surface":false,"digest":"3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1"}, {"key":"event:approval/decided","kind":"event","event":"approval/decided","surface":false,"digest":"bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3"}, {"key":"event:approval/policy","kind":"event","event":"approval/policy","surface":false,"digest":"26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78"}, @@ -18,7 +18,7 @@ {"key":"event:compaction/start","kind":"event","event":"compaction/start","surface":false,"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965"}, {"key":"event:compaction/summary","kind":"event","event":"compaction/summary","surface":false,"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"}, {"key":"event:deliverables/presented","kind":"event","event":"deliverables/presented","surface":false,"digest":"13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176"}, - {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f"}, + {"key":"event:developer/message","kind":"event","event":"developer/message","surface":true,"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a"}, {"key":"event:feedback/message-delete","kind":"event","event":"feedback/message-delete","surface":false,"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796"}, {"key":"event:feedback/message-put","kind":"event","event":"feedback/message-put","surface":false,"digest":"b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf"}, {"key":"event:feedback/record","kind":"event","event":"feedback/record","surface":false,"digest":"b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8"}, @@ -40,7 +40,7 @@ {"key":"event:session-log-deepseek/delivery-accepted","kind":"event","event":"session-log-deepseek/delivery-accepted","surface":false,"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9"}, {"key":"event:session/end-seed","kind":"event","event":"session/end-seed","surface":false,"digest":"5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e"}, {"key":"event:session/title","kind":"event","event":"session/title","surface":false,"digest":"1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5"}, - {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c"}, + {"key":"event:session/title-llm-request","kind":"event","event":"session/title-llm-request","surface":false,"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca"}, {"key":"event:step/end","kind":"event","event":"step/end","surface":false,"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05"}, {"key":"event:step/start","kind":"event","event":"step/start","surface":false,"digest":"4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986"}, {"key":"event:subagent/catalog","kind":"event","event":"subagent/catalog","surface":false,"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac"}, @@ -50,6 +50,7 @@ {"key":"event:team/member","kind":"event","event":"team/member","surface":false,"digest":"4fb59762612c3e3ac3a3bd4f84c9c148d3c3893bd422ba2b201cc039fabd49bc"}, {"key":"event:team/message/delivered","kind":"event","event":"team/message/delivered","surface":false,"digest":"48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb"}, {"key":"event:team/message/queued","kind":"event","event":"team/message/queued","surface":false,"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d"}, + {"key":"event:team/mode","kind":"event","event":"team/mode","surface":false,"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75"}, {"key":"event:team/task","kind":"event","event":"team/task","surface":false,"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a"}, {"key":"event:todo/write","kind":"event","event":"todo/write","surface":false,"digest":"b978cff734e62143eb56c9125423ec275405eda969802d42aaf73ecb987d3b26"}, {"key":"event:tool-workflow/agent-end","kind":"event","event":"tool-workflow/agent-end","surface":false,"digest":"babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7"}, @@ -62,13 +63,14 @@ {"key":"event:tool/result","kind":"event","event":"tool/result","surface":true,"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"}, {"key":"event:turn/end","kind":"event","event":"turn/end","surface":false,"digest":"0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"}, {"key":"event:turn/start","kind":"event","event":"turn/start","surface":false,"digest":"aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8"}, - {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451"}, + {"key":"event:user/message","kind":"event","event":"user/message","surface":true,"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450"}, {"key":"event:web/deepseek-search-llm-request","kind":"event","event":"web/deepseek-search-llm-request","surface":false,"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52"}, {"key":"event:working-directory/change","kind":"event","event":"working-directory/change","surface":false,"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3"}, {"key":"event:workspace/changes","kind":"event","event":"workspace/changes","surface":false,"digest":"e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72"} ], "types": [ {"digest":"0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e","names":[],"sources":[]}, + {"digest":"01c161379a3c3394a642807843e108ef911a8b7dbffd6d7de43f4484c88b0796","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, {"digest":"0203243b72fc867afc81aff5f4cf2167e564ed342534eb612fc92f295cf07789","names":[],"sources":[]}, {"digest":"022623b9ef98ec47ec3c3bae0c51027c70d2601141c5e92e26a0d83d1b4cef06","names":[],"sources":[]}, {"digest":"023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4","names":[],"sources":["packages/core/session/src/types.ts:211"]}, @@ -90,7 +92,7 @@ {"digest":"0af3ecfb8c60d416fc40a7cc4fb2c4851a8e46608b2b38ab9e510670c971aa1f","names":[],"sources":[]}, {"digest":"0b6d718c693fdf7f06dcccb0bcbbabe78cc63514eafc62d6b2a3899735279d6f","names":["packages/goal/goal/src/types.ts#GoalRef"],"sources":["packages/goal/goal/src/types.ts:20"]}, {"digest":"0b9b8facbe2c19773b3fb0ecc824ccbcc603d41b8f18069faa3807d449572fce","names":[],"sources":[]}, - {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/experimental/agent-team/src/types.ts:236"]}, + {"digest":"0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1","names":[],"sources":["packages/team/agent-team/src/types.ts:179"]}, {"digest":"0bdaf9cb63594be4ef034b68901fffe792fe09d6d5f6bac42d7f56f6e7834fb9","names":[],"sources":[]}, {"digest":"0c4205180f75b9ee1e0d878bd9a9851628b5dc18448486389d9c5fdc3f1ccda3","names":[],"sources":[]}, {"digest":"0c556c9b9f2a96238a6b9bbb99e748206cbff2ed6203041c9571f04ae5802f52","names":[],"sources":[]}, @@ -153,7 +155,7 @@ {"digest":"1fdad624334d354820dfb3a26e28d0bc9fc1b62a33f87bab63f715b3f4391bfd","names":[],"sources":[]}, {"digest":"20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5","names":[],"sources":["packages/plan/plan-mode/src/index.ts:53"]}, {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, - {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/experimental/agent-team/src/types.ts:138"]}, + {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:117"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:29"]}, {"digest":"224aa11d4d5f88c450822d01b99e6ca5e3a9c7f26a5ede10bae33384a09ef4dc","names":[],"sources":[]}, @@ -183,8 +185,7 @@ {"digest":"2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863","names":[],"sources":[]}, {"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:232"]}, {"digest":"2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c","names":[],"sources":[]}, - {"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c","names":[],"sources":[]}, - {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:129"]}, + {"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/team/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/team/agent-team/src/types.ts:108"]}, {"digest":"2f1e6a7ebace43d727ae138ddcd352eb3813960009e5805b8c5ac84300c97716","names":["packages/attachment/attachment/src/types.ts#ImageMediaType"],"sources":["packages/attachment/attachment/src/types.ts:8"]}, {"digest":"2f3f201c8846e19c20bb75c02280e3d4eb3c57509e4715f0c0a4d1d4f91a7a06","names":[],"sources":[]}, {"digest":"2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchStartEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:12"]}, @@ -226,7 +227,6 @@ {"digest":"3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796","names":[],"sources":[]}, {"digest":"3f74ba74acd10644c1ed6947a411d8088bd2cfac74f61bb8540f96d68e31f004","names":[],"sources":[]}, {"digest":"3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f","names":["packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord"],"sources":["packages/schedule/schedule/src/types.ts:161"]}, - {"digest":"4035903993dfee62cca2142c0ca51ab57075909ddee2817d0620daa97ac4d4ce","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c","names":[],"sources":[]}, {"digest":"4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41","names":[],"sources":[]}, {"digest":"42e0916e0dda5f6d1e7bb05d8514717147c036a79c9f36085683469516c1fd3f","names":[],"sources":[]}, @@ -245,6 +245,7 @@ {"digest":"48f9c19417a1abbedfa59f4667bba36b93ac2db407adf5e84cb3ba0de30942cb","names":[],"sources":[]}, {"digest":"4991dfc415c9efebc36fa6969e1798c7d2e42396cc1e6e0ac7d7c89c5c5c4aea","names":[],"sources":[]}, {"digest":"49dab019d00e8c26f27ad2ccff7b604d69e323ae4d17e0d2a7b6b8a830b1ade2","names":[],"sources":[]}, + {"digest":"4a561c111a78d4520e850c93795369ea169ff86abd082aee86c8fd38121a65aa","names":[],"sources":[]}, {"digest":"4a9bef9f27a942520cc2279ca7dd3082556e4bff543f66b5c0333e1bbed1cd86","names":["packages/subagent/subagent/src/continuation-messages.ts#AgentMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:14"]}, {"digest":"4ae007ed190a72c92138b22d059b83208079c7551ca13fb59ae8cf5e23693964","names":[],"sources":[]}, {"digest":"4aed17ec726b5397c29f6cb4bcd7905dc1a12bb6140f1657f073cd24380f2af8","names":[],"sources":[]}, @@ -252,12 +253,11 @@ {"digest":"4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731","names":["packages/llm/llm/src/message.ts#ToolResultMessage"],"sources":["packages/llm/llm/src/message.ts:173"]}, {"digest":"4b7ddaf3710b88ff9b8df66304f5f97bf03688fd19730eedc2e9535203c820ea","names":[],"sources":[]}, {"digest":"4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db","names":[],"sources":["packages/core/session/src/types.ts:198"]}, - {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/agent-team/src/types.ts#TeamId","packages/experimental/agent-team/src/types.ts#TeamMessageId","packages/experimental/agent-team/src/types.ts#TeamTaskId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:389","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/agent-team/src/types.ts:21","packages/experimental/agent-team/src/types.ts:33","packages/experimental/agent-team/src/types.ts:9","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/workflow/workflow/src/types.ts:13"]}, + {"digest":"4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b","names":["packages/api/session-controller/src/types.ts#SessionRequestId","packages/attachment/attachment/src/brand.ts#AttachmentId","packages/compaction/compaction/src/brand.ts#CompactionId","packages/core/session/src/types.ts#SessionId","packages/experimental/webhook/src/brand.ts#WebhookDeliveryId","packages/experimental/webhook/src/brand.ts#WebhookRuleId","packages/experimental/webhook/src/brand.ts#WebhookSourceId","packages/feedback/message-feedback/src/types.ts#MessageFeedbackVersion","packages/goal/goal/src/types.ts#GoalId","packages/interaction/commands/src/brand.ts#CommandId","packages/interaction/user-approval/src/types.ts#ApprovalRequestId","packages/jobs/jobs/src/brand.ts#JobId","packages/llm/llm-retry/src/brand.ts#RetryId","packages/llm/llm/src/brand.ts#MessageId","packages/llm/llm/src/brand.ts#ProviderRequestId","packages/llm/llm/src/brand.ts#ReasoningEffortId","packages/llm/llm/src/brand.ts#ToolCallId","packages/schedule/schedule/src/types.ts#ScheduleId","packages/session/session-title/src/index.ts#SessionTitleProviderId","packages/session/session-title/src/types.ts#SessionTitleProviderId","packages/team/agent-team/src/types.ts#TeamId","packages/team/agent-team/src/types.ts#TeamMessageId","packages/team/agent-team/src/types.ts#TeamTaskId","packages/workflow/workflow/src/types.ts#WorkflowRunId"],"sources":["packages/api/session-controller/src/types.ts:389","packages/attachment/attachment/src/brand.ts:6","packages/compaction/compaction/src/brand.ts:4","packages/core/session/src/types.ts:21","packages/experimental/webhook/src/brand.ts:12","packages/experimental/webhook/src/brand.ts:6","packages/experimental/webhook/src/brand.ts:9","packages/feedback/message-feedback/src/types.ts:14","packages/goal/goal/src/types.ts:17","packages/interaction/commands/src/brand.ts:31","packages/interaction/user-approval/src/types.ts:17","packages/jobs/jobs/src/brand.ts:19","packages/llm/llm-retry/src/brand.ts:4","packages/llm/llm/src/brand.ts:16","packages/llm/llm/src/brand.ts:31","packages/llm/llm/src/brand.ts:43","packages/llm/llm/src/brand.ts:67","packages/schedule/schedule/src/types.ts:14","packages/session/session-title/src/index.ts:42","packages/session/session-title/src/types.ts:17","packages/team/agent-team/src/types.ts:20","packages/team/agent-team/src/types.ts:32","packages/team/agent-team/src/types.ts:8","packages/workflow/workflow/src/types.ts:13"]}, {"digest":"4d60dddaa65d3da8d4cd014d85ce3d2c2875664dd0433e331d8e9d395289686b","names":["packages/experimental/liveshare/src/host/viewer-principal.ts#ShareViewerPrincipal"],"sources":["packages/experimental/liveshare/src/host/viewer-principal.ts:24"]}, {"digest":"4e011753f8263f436efaabfbb3b4db53a98fc55342c5ded2daf7208b217be159","names":[],"sources":[]}, {"digest":"4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d","names":[],"sources":[]}, {"digest":"4e887768586528565381dadbddee6cef555874148089f4579e0b4b1ad096fc9c","names":["packages/llm/llm/src/types.ts#TextBlock"],"sources":["packages/llm/llm/src/types.ts:69","packages/web/web-search-deepseek/src/provider.ts:66"]}, - {"digest":"4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a","names":["packages/subagent/subagent/src/continuation-messages.ts#SubagentSettledMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:29"]}, {"digest":"4edec1b770e8ec4ce1220bf42edc5ac46713a1b48d4caa778dcfc98bad1f28a3","names":[],"sources":[]}, {"digest":"4f507598cf476214aa7646ce7841a7064a9dcb05d0a1eaaefaf79c2b738f160f","names":[],"sources":[]}, {"digest":"4f91f187baeabef5c7950c4780d31bbe39f3345dff591a6cf26ac7e135ac1cf4","names":[],"sources":["packages/context/session-reference/src/types.ts:18"]}, @@ -265,7 +265,7 @@ {"digest":"50cf02d875f8a1f1411f4a6fbcffab9120ccef5f74a47331fbf29480e5f68f48","names":[],"sources":[]}, {"digest":"511801b0a6470d98566e8385d3dffddb2b67f79fcf73ed6056f46c3cc0a245a7","names":[],"sources":[]}, {"digest":"516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415","names":[],"sources":[]}, - {"digest":"51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357","names":["packages/experimental/agent-team/src/types.ts#TeamTaskSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:75"]}, + {"digest":"51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357","names":["packages/team/agent-team/src/types.ts#TeamTaskSnapshot"],"sources":["packages/team/agent-team/src/types.ts:64"]}, {"digest":"525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336","names":[],"sources":[]}, {"digest":"529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05","names":[],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734","names":[],"sources":[]}, @@ -273,7 +273,6 @@ {"digest":"530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6","names":["packages/core/session/src/types.ts#RequestHeaderReason"],"sources":["packages/core/session/src/types.ts:279"]}, {"digest":"53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac","names":[],"sources":[]}, {"digest":"53cf1704ae0709c7678bcf070c83c619fc48ca1b5c173b74cfa8702f22a8e492","names":[],"sources":[]}, - {"digest":"53d4f8256bc58109468ea358ac54d437760cd765012360cafe72a401e6a41d4a","names":[],"sources":["packages/core/session/src/types.ts:317"]}, {"digest":"54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0","names":[],"sources":["packages/core/session/src/types.ts:363"]}, {"digest":"557e148d30b79ee48645850832b30c717d7c3b64641deb5d6133b8b4e91ee0b8","names":[],"sources":[]}, {"digest":"5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c","names":[],"sources":[]}, @@ -290,7 +289,6 @@ {"digest":"5abab3a27fd2736660ecddd94b28d6bbf114986dd894880153bd1be3ccacb563","names":[],"sources":[]}, {"digest":"5b232a9b9bfcc0fc6794dab7865bcf203a65785c5de5aa647a51228bff628413","names":[],"sources":[]}, {"digest":"5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51","names":[],"sources":["packages/interaction/commands/src/types.ts:112"]}, - {"digest":"5bb231bc5afdb532a6898a20604e76bf0c1db438bfcdc17d4847dceaddc02451","names":[],"sources":[]}, {"digest":"5bb69b88517f6214f2af4d1a6d2b771e271ad3ad695cc9c2c272069ae698c9bc","names":[],"sources":[]}, {"digest":"5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e","names":[],"sources":[]}, {"digest":"5bd310104cfe448e530beb11422d2f791e2e32e8e95fdc528a15f9ca693dae75","names":[],"sources":[]}, @@ -307,16 +305,18 @@ {"digest":"5f2117d1856a5ab43c28371faa978ebfaa48f8c7d734ea3fc94842210df83dca","names":["packages/llm/llm/src/types.ts#LlmFailure"],"sources":["packages/llm/llm/src/types.ts:48"]}, {"digest":"5f26a6c20b37632f8f57729d171c671def4683d994ac6257a8dffcd855101627","names":[],"sources":[]}, {"digest":"5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564","names":["packages/subagent/subagent/src/descriptor.ts#OneShotSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:61"]}, + {"digest":"601327bedca5f98a0fa4c27133bd35749f3af2baaaacf53badf074e59cea5d25","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"6085488d223aeffdef51d7ae17655fc26ab22ee13cbed82a50e62e2b6fb5b918","names":[],"sources":[]}, {"digest":"611cae9a3539733aa0c42f66035a7d90f19dcbbdfb5d9e3b628b13705253942d","names":[],"sources":[]}, {"digest":"611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da","names":["packages/jobs/jobs/src/types.ts#JobSettledData"],"sources":["packages/jobs/jobs/src/types.ts:40"]}, + {"digest":"613660455704fb82a825b5b766e000aa64b5b4d44b4f10e55cffe968d4ec6ab1","names":["packages/llm/llm/src/message.ts#UserMessage"],"sources":["packages/llm/llm/src/message.ts:162"]}, {"digest":"616f93a5047de668e1c30d57b1786abd76beeb4123599637ec1dc5290320ede5","names":[],"sources":[]}, {"digest":"6292bcbedaa97feaec5b48a03bb8144b581b93309d9d6c905f26da7a6a7a0a4f","names":[],"sources":[]}, {"digest":"62e6a429ff3f390a46ac39fb468692032f42936ca0db19b53454184189401379","names":[],"sources":[]}, {"digest":"62ecb62dc0fe1b5e70217f8a80c39256a40ee3a1917be14e5ca899d20c35f7fd","names":[],"sources":[]}, {"digest":"6487b10ba7542f3c447b1e41a1c6a590b7bfafe0b9b66b49f34028da85bb970f","names":["packages/subagent/subagent/src/descriptor.ts#ContinuableSubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:72"]}, - {"digest":"64ef542b1bfc988382d337f2ea85aab8ad76ea55341cf082163919d315688428","names":["packages/llm/llm/src/message.ts#DeveloperMessage"],"sources":["packages/llm/llm/src/message.ts:157"]}, - {"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/experimental/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/experimental/agent-team/src/types.ts:48"]}, + {"digest":"65377a76c6a58f785d95b89f89f2e13d289cb88a70ea74939d0ec08f429f0a6e","names":[],"sources":["packages/core/session/src/types.ts:317"]}, + {"digest":"6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25","names":["packages/team/agent-team/src/types.ts#TeamMemberSnapshot"],"sources":["packages/team/agent-team/src/types.ts:50"]}, {"digest":"65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad","names":["packages/llm/llm/src/message.ts#SystemPromptMessageSource"],"sources":["packages/llm/llm/src/message.ts:35"]}, {"digest":"65d583e66518c5236015e0846470fb32efcf92375ed306aef14e50a9d2cd2099","names":["packages/skill/tool-skill/src/index.ts#SkillCatalogSource"],"sources":["packages/skill/tool-skill/src/index.ts:35"]}, {"digest":"66ba70f6f068da443f969a0b947a013c3addd776b9b60a0a393759c19825e9a2","names":["packages/llm/llm/src/types.ts#ToolSchema"],"sources":["packages/llm/llm/src/types.ts:517"]}, @@ -325,6 +325,7 @@ {"digest":"68141cbe3994a5ffcd064c07c75996538437fd9dbf77ce8a2927e89ec577fabe","names":[],"sources":[]}, {"digest":"688e853dc149f0d30b31765c12ec7e3be74169bdf1a4c6463a10b945d50cc1b8","names":[],"sources":[]}, {"digest":"68f44f59e91c41395076cb77265981a601680ec8a381bbbeb6ab754ffdc94573","names":[],"sources":[]}, + {"digest":"690583582246babf6120f16eefd849dd760c5ec84619014951af403a1b3032c0","names":[],"sources":[]}, {"digest":"69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1","names":[],"sources":[]}, {"digest":"693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057","names":[],"sources":["packages/llm/llm/src/types.ts:168"]}, {"digest":"69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199","names":[],"sources":[]}, @@ -339,7 +340,7 @@ {"digest":"6ccb6bc423fb3952458abf9c1b1c3284208b7e660344b612cc55da773d61afe4","names":[],"sources":[]}, {"digest":"6cda0d6d3b4336d83998fe580628426df81c2afbab5d768b8813b56d569120e8","names":[],"sources":[]}, {"digest":"6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65","names":[],"sources":[]}, - {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:31"]}, + {"digest":"6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406","names":[],"sources":["packages/preset/agent-preset-registry/src/session.ts:30"]}, {"digest":"6ea608bdc8a7a63d3d23b84c49721325094db88d136482e43a820945295eee3e","names":[],"sources":[]}, {"digest":"6ebcc2dbec9f2dabca9f668ae6d39e877899b180740570f453409db339221df3","names":[],"sources":[]}, {"digest":"6f091fe32ab24407236090ca9d710bbaf59bb57466e9f3b8f1f6e4862bc863c6","names":[],"sources":[]}, @@ -349,7 +350,7 @@ {"digest":"6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20","names":["packages/llm/llm-retry/src/types.ts#LlmRetryStartedEventData"],"sources":["packages/llm/llm-retry/src/types.ts:43"]}, {"digest":"6f9e00383836275e46c0a9915767f9bb561be5fc30aae490174908a93e99a912","names":[],"sources":[]}, {"digest":"70b082fe78991edf13cf66e1b4d06cd2dcff9cacfd76f8cfb996bfb61939e7b2","names":[],"sources":[]}, - {"digest":"70d2acdec1ebcfea56d02f7233ebb9ec0f7f494447fb952884c22c3afdc01d6f","names":[],"sources":[]}, + {"digest":"70ea8e7943c5e2d76905bcc2e694e8c3d5b138aaae3609f3e261b8d369a6e35a","names":[],"sources":[]}, {"digest":"7180844e1066230c5e891f1b9ccd942423b0fe48aac6ded7eef2a285b288a732","names":[],"sources":[]}, {"digest":"719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91","names":["packages/llm/llm/src/message.ts#ModelMessageSource"],"sources":["packages/llm/llm/src/message.ts:24"]}, {"digest":"71bb3254ec8f1e491ce63b762a7b5f18709de23aa4e8abcc8b886f4b6ce051dd","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:28"]}, @@ -367,20 +368,17 @@ {"digest":"7a651f3b672c52fb615f22c0ce9cb3cc5b22aad92e6bbeefb0808fd7b9233a50","names":[],"sources":[]}, {"digest":"7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb","names":[],"sources":[]}, {"digest":"7b1ce60193fa8b69292e5843858d9cee7dc565dcb40d33c136e5d7e8b8d4296a","names":[],"sources":[]}, - {"digest":"7c203f3d4dfa6638ff2601247cc7ca5d46731d2c614aa61217475b9eb1fbd0b3","names":[],"sources":[]}, {"digest":"7c6129b6961cf4b2a030f36c78d2b0a5be38617651a9b81322e054ee512bee06","names":["packages/feedback/message-feedback/src/types.ts#MessageFeedbackDelete"],"sources":["packages/feedback/message-feedback/src/types.ts:46"]}, {"digest":"7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397","names":[],"sources":[]}, {"digest":"7d226b9fd95b4ba213d4237c22e930a91c3c769c95ca7f92977b9d625ba13438","names":["packages/goal/goal/src/domain.ts#GoalChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:44"]}, {"digest":"7d54a5a5e629e2e7783bfac72683bcc5e969e9f43426b9289826aaf294ff6344","names":["packages/feedback/command-feedback/src/types.ts#FeedbackRecord"],"sources":["packages/feedback/command-feedback/src/types.ts:27"]}, {"digest":"7d65acf0a3a43a8f445ce5d3320b42eca492739fc931b2a7516bfb8368dc2c68","names":["packages/llm/llm/src/types.ts#ContentBlockType"],"sources":["packages/llm/llm/src/types.ts:155"]}, {"digest":"7df62978a2b7de8540e714152297743e0a622209a46a5ac9513fc0fad73ca23b","names":[],"sources":[]}, - {"digest":"7e9f1072c17f7aa86547c9c8b02766c1006d866a1859a3d677a336e932f70d1a","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"7eaef7a763ba5b949b8464bc8add8980a3a5ab566014b69787aeba71e5dfe07b","names":[],"sources":[]}, {"digest":"7f13860ee89a2d35ce09f958e85a04566cd59cb1de1d73a9619a9b631961dc71","names":[],"sources":[]}, {"digest":"7f7fd5a6b0064f597534b29ff62ef26e786dffccf5e14f654a7d4fcea2c35f04","names":[],"sources":[]}, {"digest":"7f9365bae9fcb73e1e34061a1acfe0422c1a5fd351848cace9bebbe806f28462","names":["packages/workflow/tool-workflow/src/types.ts#ToolWorkflowRunEndData"],"sources":["packages/workflow/tool-workflow/src/types.ts:36"]}, {"digest":"7f959241c24128b4983b559984811861dc59774b85905c8fa57c6cb0148a3dbc","names":["packages/goal/goal/src/domain.ts#GoalMessageSource"],"sources":["packages/goal/goal/src/domain.ts:47"]}, - {"digest":"7ff62f6599c083ceb5a0d7a8940ff85ba80dc1d7ddd6ba9339903e93f74cba54","names":["packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"],"sources":["packages/session/session-title-llm/src/index.ts:43"]}, {"digest":"8064c03c87c357fd1575cb9395ce86e6dcbe2d275bf7717864a02cddf01827d5","names":["packages/llm/llm/src/types.ts#ContentBlock"],"sources":["packages/llm/llm/src/types.ts:157"]}, {"digest":"80677c8c8b804189a0fde8aa70dd4ad7af8bb12dfd03027e8e023c8592c838bc","names":[],"sources":[]}, {"digest":"8067b0d232c350f189738e60ee3c9939d46ca3b32b45db5525237b077893f649","names":[],"sources":[]}, @@ -395,6 +393,7 @@ {"digest":"83d6916021eede8f2d83a1872716caa1efdec39a3681890996c55147187fdd12","names":[],"sources":[]}, {"digest":"83da72c6857601c75da11212ef847f57d457b9c99f0a82246dcdff3e26700239","names":["packages/context/agent-instructions/src/render.ts#AgentInstructionChange"],"sources":["packages/context/agent-instructions/src/render.ts:62"]}, {"digest":"8465ece5e9d9007c038b357015d849c15262cb0bc25abc59e47692d87ee01a15","names":["packages/workflow/workflow/src/types.ts#WorkflowStopReason"],"sources":["packages/workflow/workflow/src/types.ts:63"]}, + {"digest":"848639f8787f035b480ec53cb5686850897c5e8006d87ca231cd8a9ee8634936","names":["packages/subagent/subagent/src/continuation-messages.ts#SubagentSettledMessageSource"],"sources":["packages/subagent/subagent/src/continuation-messages.ts:29"]}, {"digest":"84ac8c1a66ffc697262e181f42e851259eae14038c113ca329fb4700ccdb1f7c","names":["packages/goal/goal/src/types.ts#GoalBlockReason"],"sources":["packages/goal/goal/src/types.ts:52"]}, {"digest":"84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7","names":[],"sources":[]}, {"digest":"854db3efdfb0cd39d30953b28703e519c121f1115cebfffc40369fe86c406dd1","names":[],"sources":[]}, @@ -402,6 +401,7 @@ {"digest":"85f9b505b7bdff1f61845c4cea8f36fd0fde1b62facbe87d8924d1d27e616f3a","names":[],"sources":[]}, {"digest":"85fe96bf15d5c7559eec3bae4d6cf1a76fda9f5be05a29c2830cff560cab0c0d","names":[],"sources":[]}, {"digest":"8620189c8e33582708ece48fd2d20dc4d57912486a17bdbb9aea4c44f7018062","names":[],"sources":[]}, + {"digest":"866f7b2dbdc5b421eab3e629312d1c839b75c259633b4b43b50d23bac44d92a0","names":[],"sources":[]}, {"digest":"86d1d0e9cfebb240421187e3dc37fb71cc38a1501885cf32f8f9adef7b0a2bde","names":["packages/subagent/subagent/src/descriptor.ts#SubagentDescriptorData"],"sources":["packages/subagent/subagent/src/descriptor.ts:89"]}, {"digest":"86e1e384495ee09ac2d60c9db3610f1f1fddac2d248f6f1d6000e78054680219","names":["packages/llm/llm/src/message.ts#ContextSnapshotSection"],"sources":["packages/llm/llm/src/message.ts:70"]}, {"digest":"871bfd6f7bb91061246768d3377f3fd58d6b8ea7c31f44c714ae7ab215650d62","names":[],"sources":[]}, @@ -425,6 +425,7 @@ {"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]}, {"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:28"]}, {"digest":"904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd","names":[],"sources":[]}, + {"digest":"920d87e22b7409bcc7224f8b1dc7f5101c33574c75627c65baab12b85cc96e75","names":[],"sources":[]}, {"digest":"930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7","names":["packages/interaction/user-approval/src/types.ts#ApprovalOutcome"],"sources":["packages/interaction/user-approval/src/types.ts:32"]}, {"digest":"935c8d486292b13c87b57ca280eb8c58b5b2f5239be86fcd4334c33fdeb87682","names":[],"sources":[]}, {"digest":"938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615","names":[],"sources":[]}, @@ -434,6 +435,7 @@ {"digest":"94c56aeb1564ad735eb773d88636b40761d063efa1610f1308004d32923f5378","names":[],"sources":[]}, {"digest":"95080295115fbd83544520ba247b68a03790131f6a2eac323d388758cb542f65","names":[],"sources":["packages/llm/llm/src/types.ts:498"]}, {"digest":"95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd","names":[],"sources":[]}, + {"digest":"95b5cf90ea1f57da3319184d6f5f3811d0f651aa9113591c10e81c3d1e9b9f70","names":[],"sources":[]}, {"digest":"95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60","names":[],"sources":[]}, {"digest":"9645cf93769a6dfb227bd400bd6cb9fa7bab9140f4a851e4ae92514ade6e3114","names":[],"sources":[]}, {"digest":"96bfb916a727cd4445a594df264c523fc039749a31bfed924b8cc7bb308a5bb0","names":[],"sources":[]}, @@ -471,7 +473,7 @@ {"digest":"a3a259a1aa5401af786ce08c4a8419681d3000d73467991b5fc563389bc4ebd2","names":[],"sources":[]}, {"digest":"a3c6f1ea38e9a61d7a9706057ba65aabe02dfe6d9dd90d4736aa62325d874531","names":[],"sources":[]}, {"digest":"a3edd1efd1fef21b72c55e213c8a8b24196b091abde2b89db52be0b2aaae3cfa","names":[],"sources":[]}, - {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/experimental/agent-team/src/types.ts:232"]}, + {"digest":"a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c","names":[],"sources":["packages/team/agent-team/src/types.ts:175"]}, {"digest":"a4afec59b75aad5d13ef82d6931f35cc2d98918503916cf069355b77c5e42302","names":[],"sources":["packages/api/session-controller/src/types.ts:398"]}, {"digest":"a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc","names":[],"sources":[]}, {"digest":"a6b41f5bede8094d7bf635be5d69b1bd7a25e006ca9cf9415bceb9322f1a7feb","names":["packages/experimental/hook-protocol/src/types.ts#HookDialect"],"sources":["packages/experimental/hook-protocol/src/types.ts:48"]}, @@ -508,14 +510,15 @@ {"digest":"b0127044ab31a702bddfd785d345f5abd7a70876746e895ce443afa3e60ddf2d","names":[],"sources":[]}, {"digest":"b062078e899958f99ae464514578b8c093924dfe34bd9a835932b420ff2150b1","names":[],"sources":[]}, {"digest":"b0b0eb1b16490f6bee135b2377e8f8c39227974c799ad7d49ff418af23ecbc35","names":["packages/attachment/attachment/src/types.ts#ImageAttachmentRef"],"sources":["packages/attachment/attachment/src/types.ts:11"]}, + {"digest":"b120362fd216a16a40fe787f6d3286086aa131266322bab79c026a130ccd0f72","names":["packages/llm/llm/src/message.ts#MessageSource"],"sources":["packages/llm/llm/src/message.ts:136"]}, {"digest":"b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081","names":["packages/core/session/src/types.ts#RequestContext"],"sources":["packages/core/session/src/types.ts:260"]}, {"digest":"b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7","names":[],"sources":[]}, - {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/experimental/agent-team/src/types.ts:230"]}, + {"digest":"b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1","names":[],"sources":["packages/team/agent-team/src/types.ts:173"]}, {"digest":"b1f62380f6746fedd629e71065e3583cef83334875b499ac9665c2eb87acfbf5","names":[],"sources":[]}, {"digest":"b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e","names":[],"sources":[]}, {"digest":"b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626","names":[],"sources":[]}, {"digest":"b2a5c8c008a31e3dc6c3013502f2356aca13894b31c8e70601fa92f2d40d1dba","names":[],"sources":[]}, - {"digest":"b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943","names":["packages/experimental/agent-team/src/types.ts#TeamTaskStatus"],"sources":["packages/experimental/agent-team/src/types.ts:72"]}, + {"digest":"b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943","names":["packages/team/agent-team/src/types.ts#TeamTaskStatus"],"sources":["packages/team/agent-team/src/types.ts:61"]}, {"digest":"b3681c026a0b98e98b103f057dfd217fbe904129c090efd668253ec9aea7fece","names":[],"sources":[]}, {"digest":"b38f206d59bad891fa58c89f97a0036987b3defe7685acbd318fa55eb85f9751","names":[],"sources":[]}, {"digest":"b4ec0af39fd52ed971990e42f04e5be3204882e555c409eb330f8238eec21569","names":[],"sources":[]}, @@ -558,6 +561,7 @@ {"digest":"c1f0f17b6493b85db6453bb30e01b208d703731b25bc1e68e5d4fc72eea9cdea","names":["packages/llm/llm/src/types.ts#ToolCallBlock"],"sources":["packages/llm/llm/src/types.ts:112"]}, {"digest":"c1f9e0405de6d18cabb9ee70782a027f9bbdc57e5abec9dcccdd56119e2e9058","names":[],"sources":[]}, {"digest":"c219efbdfd53b836786202ab4532913d555798f0a8615a4e9629736e3799fb70","names":[],"sources":[]}, + {"digest":"c2a13242efc3bea4b36a843ddf1524ff4d33c765a71b582fa1fdbfa4fa148434","names":[],"sources":["packages/team/agent-team/src/types.ts:171"]}, {"digest":"c2deb7c9183735d4e0fffddf5c623f4e34daa1c8e96b111982e957dfcbbcf896","names":["packages/workflow/workflow/src/types.ts#WorkflowAgentOutcome"],"sources":["packages/workflow/workflow/src/types.ts:110"]}, {"digest":"c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3","names":[],"sources":[]}, {"digest":"c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943","names":[],"sources":[]}, @@ -567,7 +571,6 @@ {"digest":"c589e938cc0e4989df8b82abef6339de621f0dc4fe62c230d145b94996af7f92","names":[],"sources":[]}, {"digest":"c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6","names":["packages/util/values/src/index.ts#JsonValue"],"sources":["packages/util/values/src/index.ts:4"]}, {"digest":"c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262","names":[],"sources":[]}, - {"digest":"c78b9097bfae83cd08f0a98a92730236ba80ac61d712046959e4fc7f28aa1f36","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"c8e6f48878cc2a2f0dc59682280d81f407943fb503f32c64bf9ff3d9393c3eff","names":[],"sources":[]}, {"digest":"c99e20441bf15341e9db54385e558e5c03d21dff2eb1ac08ccbf47c2d8747954","names":[],"sources":[]}, {"digest":"ca085dba2c877feceebf3c62f9d39c6c2a4d2314d97b94b3927a780fe8fe2bb2","names":[],"sources":[]}, @@ -579,21 +582,21 @@ {"digest":"cda2345a9be9d139df49ccbd16dcba716f3440178bf20ff8b801fda1f5eb77dc","names":[],"sources":[]}, {"digest":"cdca80b8dad24d4e7b1d5a288a8475ba8d8d46be385bd2da85f122ee836f1670","names":[],"sources":[]}, {"digest":"cdee10e87dbed8440d8b6935888624884878efc7af95402e94171b29f173dced","names":[],"sources":["packages/llm/llm-retry/src/types.ts:17"]}, - {"digest":"cdf988172b589b230f2bce868489def1bb575eb0501076ac757a8f369ac5c64e","names":[],"sources":[]}, {"digest":"cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6","names":["packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData"],"sources":["packages/core/tool-executor-ptc/src/types.ts:21"]}, {"digest":"cffcdc19881ab61ec0a9ce18743996f7ac44ac8889f34dfac36ae745574aa22f","names":["packages/goal/goal/src/domain.ts#GoalClearChangeMeta"],"sources":["packages/goal/goal/src/domain.ts:35"]}, {"digest":"d0fbb0ca1bbba059830826f33f801da1aced35a2504376d61e020bdfec8d50c0","names":[],"sources":[]}, {"digest":"d28765f87d6b6ab408e0b2f3e7e297791b004b806d8e302c49903ebae815feb2","names":[],"sources":[]}, {"digest":"d4b739491689cc6bb13d07ffac1a32239875abda364c0d5edd8f74faf5e9c00d","names":[],"sources":["packages/llm/llm/src/types.ts:501"]}, {"digest":"d4c799e3d51cae1040362ba4968efc484739e14416c9c1df20c2f108b4ef2eb5","names":[],"sources":[]}, - {"digest":"d4d960185026266ed7bd127be1e252234689e8b4890c7c2677a644ba58f35401","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, {"digest":"d4e4d575abeb4b72d616dc65c17ac0eaa943cc135609395044c38d5afcb10e31","names":[],"sources":[]}, {"digest":"d595ec73b32b016a6055333c67a5d646032b22e672da1d4a09c5b1ae398a093a","names":[],"sources":[]}, {"digest":"d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9","names":[],"sources":[]}, {"digest":"d66e11dddf5dfa3c71547e2cbd7b0608824cd8d6243fc16f532017ff28e8fba5","names":[],"sources":[]}, {"digest":"d6e654b88b584fcdf290a56802b1730bc7b9847ae86baa4265ab7769eb48990c","names":[],"sources":[]}, {"digest":"d85590a5675c6e2fd8164e5792a6cceac714605b8ac228adae5ee668facb1490","names":[],"sources":[]}, + {"digest":"d8a476d2c6fd30df6ec0e799f91716d1518c780d23b4121dce545abfcfb7c5a5","names":[],"sources":[]}, {"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]}, + {"digest":"d95c624eddecdacf1ee74356196c09d2f3fcd625714be5e852a6b643fa12a7ca","names":[],"sources":[]}, {"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]}, {"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]}, {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:36"]}, @@ -603,10 +606,12 @@ {"digest":"db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965","names":[],"sources":[]}, {"digest":"dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd","names":[],"sources":["packages/core/tool-executor-ptc/src/types.ts:68"]}, {"digest":"dbdafceb9a900183cccc80596498d386f3bf7bddead2b8cfde68453aa4739edc","names":[],"sources":[]}, + {"digest":"dcb07e04496586d2664d72dab0e94312917da56927b2a0dc3ee53d6a33e32450","names":[],"sources":[]}, {"digest":"dd00cdccd0ba487b92362ef67db58cd264f48c1bfd368291424a1da9a899489a","names":[],"sources":[]}, {"digest":"dd2780e7e07684332f7a9ab047237798d260e7eaffb1321cb38eca695d184c3d","names":[],"sources":[]}, {"digest":"dd5ee8762bd8a14b6c6bffedd1753e4e78505bb0f9ea1621018d03a54eef59d5","names":[],"sources":[]}, {"digest":"dd6cc2bb2a8f28248347021ad93565360b196a042360b4cd16b569d3a2a91f2d","names":[],"sources":[]}, + {"digest":"ddce54a3873dc57113b0cd38ad9b23b3c259186139e8847e5e5f415d86f52482","names":[],"sources":[]}, {"digest":"dde0cdf9d426768b649b5a9783d2144e7f900c35ef65229cd89036dbf411d1b0","names":[],"sources":[]}, {"digest":"de25352d2dc4196309bd84b5bac6319f6d5c898ea0e82bb5ae66c5738d0d426f","names":[],"sources":[]}, {"digest":"de30b1fa12c75f81df6aa4096e1595296d2d04e2be14a7c29849cf8028b79048","names":[],"sources":[]}, @@ -614,8 +619,10 @@ {"digest":"deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5","names":[],"sources":["packages/typert/protocol/src/types.ts:457"]}, {"digest":"df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528","names":[],"sources":[]}, {"digest":"df843efd87c78cf8de98f8e0d0216956cd568d0d0e56a22c853663e274aa1a18","names":[],"sources":[]}, - {"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/experimental/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/experimental/agent-team/src/types.ts:45"]}, + {"digest":"dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd","names":["packages/team/agent-team/src/types.ts#TeamMemberPhase"],"sources":["packages/team/agent-team/src/types.ts:47"]}, {"digest":"e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05","names":[],"sources":[]}, + {"digest":"e1880c67e735e228f3555df8a55a492db2c0b0c71bc8859e938e184a46c7db51","names":[],"sources":["packages/core/agent/src/types.ts:110"]}, + {"digest":"e272493e3ea0b33b2e1d64b6a80e9d8bd3d1d466cc115f2facbdd598f8644a39","names":["packages/llm/llm/src/message.ts#Message"],"sources":["packages/llm/llm/src/message.ts:196"]}, {"digest":"e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187","names":[],"sources":[]}, {"digest":"e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa","names":[],"sources":[]}, {"digest":"e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602","names":[],"sources":[]}, @@ -640,7 +647,7 @@ {"digest":"e7f4cbb3a57f4cbdb61ea41e95bcba626ad1fff6bb9b1e063b1d50a32da6e864","names":[],"sources":[]}, {"digest":"e8de0f457fed5b9ff94dc4950b59ef75fd8ba8af665cc7899485924a62653367","names":[],"sources":[]}, {"digest":"e95f957154b2b1b58692af53d4131180b6dd19e2264869f876dbaa959991494e","names":[],"sources":[]}, - {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/experimental/agent-team/src/types.ts:234"]}, + {"digest":"ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403","names":[],"sources":["packages/team/agent-team/src/types.ts:177"]}, {"digest":"eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c","names":[],"sources":["packages/core/session/src/types.ts:201"]}, {"digest":"ebb768d85ec87fb68e42fc14fd7d8535b5ac11791b14d04c6770180f0a719bf8","names":[],"sources":[]}, {"digest":"ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93","names":[],"sources":[]}, @@ -692,11 +699,11 @@ {"digest":"fe1a7acdeed4d0f5ab483396eaa5b3dc97263b7c10eb4da2f0028cb01728bfc9","names":[],"sources":[]}, {"digest":"fe792a6cd430494503837e21e79f91677704ccdd9e870c679aabd7d43ed47ddd","names":[],"sources":[]}, {"digest":"fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394","names":["packages/llm/llm/src/types.ts#TokenUsage"],"sources":["packages/llm/llm/src/types.ts:182"]}, - {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]}, - {"digest":"ff2cbb53582d1827b02622482246979942f59e07e0393b76a161251423549e72","names":[],"sources":[]} + {"digest":"fee1caf8ab6a65c1bc981f5a1730164490f22a2a89ffa3d0b76f1f432c44c8b5","names":[],"sources":[]} ], "nodes": { "0116b1f89c05221d": {"kind":"literal","value":"web_search_20250305"}, + "01c161379a3c3394": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "0203243b72fc867a": {"kind":"object","properties":[{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, "022623b9ef98ec47": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "023a355b2be0d688": {"kind":"object","properties":[{"name":"kind","type":"254dce209e735f9c","optional":false}],"indices":[]}, @@ -811,7 +818,6 @@ "2da725e554cd8930": {"kind":"literal","value":"fork"}, "2e3523541e942ef6": {"kind":"union","types":["18dc21f12bbac8ad","8e8e91646a5cae3f","e6063d0451dc0202","eea687d437880bd5"]}, "2e4debca12f2c449": {"kind":"literal","value":"team-message"}, - "2e505098f9209e52": {"kind":"object","properties":[{"name":"data","type":"7ff62f6599c083ce","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "2eafefdc6f79a492": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderId","type":"4cd48f3e5108bd6e","optional":false},{"name":"senderName","type":"4cd48f3e5108bd6e","optional":false},{"name":"targetId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "2f1e6a7ebace43d7": {"kind":"union","types":["3f74ba74acd10644","58f94390b982f8a5","de30b1fa12c75f81","ebb768d85ec87fb6"]}, "2f3f201c8846e19c": {"kind":"literal","value":"tool-goal"}, @@ -854,7 +860,6 @@ "3ee93b06f3a12585": {"kind":"object","properties":[{"name":"data","type":"7c6129b6961cf4b2","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44818000e56acb97","optional":false}],"indices":[]}, "3f74ba74acd10644": {"kind":"literal","value":"image/jpeg"}, "3fdf9582c3b5597d": {"kind":"object","properties":[{"name":"afterSeconds","type":"0657d3ca5ac4e7bf","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"5b232a9b9bfcc0fc","optional":false},{"name":"prompt","type":"4cd48f3e5108bd6e","optional":false},{"name":"scheduledAt","type":"4cd48f3e5108bd6e","optional":false},{"name":"title","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, - "4035903993dfee62": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "408927267422a7b2": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"0fe52387f6505270","optional":false}],"indices":[]}, "4208123b50df5006": {"kind":"object","properties":[{"name":"data","type":"ad0c38a0adb82a94","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"3e0eff5946255ff0","optional":false}],"indices":[]}, "42e0916e0dda5f6d": {"kind":"object","properties":[{"name":"data","type":"7f9365bae9fcb73e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fe792a6cd4304945","optional":false}],"indices":[]}, @@ -873,6 +878,7 @@ "48f9c19417a1abbe": {"kind":"object","properties":[{"name":"data","type":"0bcfaf5566d27102","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"a8a2b9011fc06786","optional":false}],"indices":[]}, "4991dfc415c9efeb": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "49dab019d00e8c26": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, + "4a561c111a78d452": {"kind":"literal","value":"refusal"}, "4a9bef9f27a94252": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"82470020b5bfcb1a","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "4ae007ed190a72c9": {"kind":"literal","value":"llm/retry-started"}, "4aed17ec726b5397": {"kind":"tuple","elements":[{"type":"2d11ca7b0d4493e2","optional":false,"rest":false}]}, @@ -885,7 +891,6 @@ "4e011753f8263f43": {"kind":"literal","value":"team/message/queued"}, "4e44c9118152f682": {"kind":"literal","value":"text"}, "4e88776858652856": {"kind":"object","properties":[{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"4e44c9118152f682","optional":false}],"indices":[]}, - "4e97c3d85c0fc817": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "4edec1b770e8ec4c": {"kind":"literal","value":"ptc-mode"}, "4f507598cf476214": {"kind":"literal","value":"series"}, "4f91f187baeabef5": {"kind":"object","properties":[{"name":"capturedFormatVersion","type":"0657d3ca5ac4e7bf","optional":true},{"name":"capturedThroughSeq","type":"3bd652ebfa8726b3","optional":false},{"name":"compacted","type":"aa93010d0e521ad7","optional":false},{"name":"inputIndex","type":"0657d3ca5ac4e7bf","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"omittedBytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"omittedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"originalMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retainedMessages","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"truncated","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, @@ -901,7 +906,6 @@ "530da6c0267813bb": {"kind":"union","types":["34d1ab5c5df37818","4f507598cf476214","e7ce49f12953583f","f789af5ab844827e"]}, "53750b9abf61efb7": {"kind":"object","properties":[{"name":"data","type":"f731ffe80810b028","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"44c8c77aa9aa100b","optional":false}],"indices":[]}, "53cf1704ae0709c7": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, - "53d4f8256bc58109": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"64ef542b1bfc9883","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "54f9276a1617d7c1": {"kind":"object","properties":[{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"stream","type":"84bbfdce7d2eab5b","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "557e148d30b79ee4": {"kind":"literal","value":"agent-preset/selected"}, "5654685d7557ec74": {"kind":"object","properties":[{"name":"form","type":"6f9e00383836275e","optional":false},{"name":"kind","type":"f68c335306535a00","optional":false},{"name":"sections","type":"c748fb42c80a1200","optional":false}],"indices":[]}, @@ -918,7 +922,6 @@ "5abab3a27fd27366": {"kind":"literal","value":"text-delta"}, "5b232a9b9bfcc0fc": {"kind":"literal","value":"after"}, "5b6fb1f226ff5640": {"kind":"object","properties":[{"name":"commandId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"1ab2ef1296f157ba","optional":false},{"name":"sourceEventSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, - "5bb231bc5afdb532": {"kind":"object","properties":[{"name":"data","type":"4035903993dfee62","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "5bb69b88517f6214": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "5bc66269cfaf614c": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"d9edf1862e4471d6","optional":false}],"indices":[]}, "5bd310104cfe448e": {"kind":"literal","value":"resource-cost"}, @@ -935,15 +938,17 @@ "5f2117d1856a5ab4": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"message","type":"4cd48f3e5108bd6e","optional":false},{"name":"offloadImages","type":"0657d3ca5ac4e7bf","optional":true},{"name":"providerRetryAfterMs","type":"0657d3ca5ac4e7bf","optional":true},{"name":"requestId","type":"4cd48f3e5108bd6e","optional":true},{"name":"status","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, "5f26a6c20b37632f": {"kind":"object","properties":[{"name":"data","type":"9a410ddf56792b2b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ad92f473a03ae725","optional":false}],"indices":[]}, "5f758f3de5f00c18": {"kind":"object","properties":[{"name":"label","type":"4cd48f3e5108bd6e","optional":true},{"name":"mode","type":"d28765f87d6b6ab4","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "601327bedca5f98a": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"95b5cf90ea1f57da","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "6085488d223aeffd": {"kind":"object","properties":[{"name":"form","type":"fee1caf8ab6a65c1","optional":false},{"name":"kind","type":"2f3f201c8846e19c","optional":false}],"indices":[]}, "611cae9a3539733a": {"kind":"literal","value":"llm/retry"}, "611d82d2dc50df70": {"kind":"object","properties":[{"name":"cause","type":"750a2b129d67704d","optional":false},{"name":"detail","type":"4cd48f3e5108bd6e","optional":true},{"name":"finishedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"jobId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"owner","type":"4cd48f3e5108bd6e","optional":false},{"name":"producerReported","type":"aa93010d0e521ad7","optional":false},{"name":"startedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"status","type":"c2f49004d9ae38a9","optional":false},{"name":"toolCall","type":"39cf2d3ff8bac81c","optional":true}],"indices":[]}, + "613660455704fb82": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"68f44f59e91c4139","optional":false},{"name":"source","type":"b120362fd216a16a","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.user-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, "616f93a5047de668": {"kind":"literal","value":"tool-call-chunks"}, "6292bcbedaa97fea": {"kind":"object","properties":[{"name":"kind","type":"f68c335306535a00","optional":false}],"indices":[]}, "62e6a429ff3f390a": {"kind":"union","types":["1d8798b1a5e8c545","1e346446e183ee76","34d1ab5c5df37818","7624ca28c61ca0ea","ad22a5ca5bbaab2e","d9196086e41e4dd5"]}, "62ecb62dc0fe1b5e": {"kind":"literal","value":"next-turn"}, "6487b10ba7542f3c": {"kind":"object","properties":[{"name":"agentModel","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentProvider","type":"4cd48f3e5108bd6e","optional":true},{"name":"agentReasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"persona","type":"4cd48f3e5108bd6e","optional":true},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"toolFilter","type":"3cfc3a56502da1f8","optional":true},{"name":"version","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "64ef542b1bfc9883": {"kind":"object","properties":[{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"role","type":"589d0d91239681f8","optional":false},{"name":"source","type":"7e9f1072c17f7aa8","optional":false,"compatibility":{"version":1,"policy":"session-source-attribution","binding":"session.developer-message.source","discriminator":"kind","unknownKinds":"preserve","attributionKinds":["action-cards","tmux-context","user-question-reply","working-directory"]}}],"indices":[]}, + "65377a76c6a58f78": {"kind":"object","properties":[{"name":"headerSeq","type":"0657d3ca5ac4e7bf","optional":true},{"name":"message","type":"01c161379a3c3394","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6598cd6da253948d": {"kind":"object","properties":[{"name":"context","type":"50cf02d875f8a1f1","optional":false},{"name":"description","type":"4cd48f3e5108bd6e","optional":false},{"name":"error","type":"4cd48f3e5108bd6e","optional":true},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"phase","type":"dfce5db4aa42b7eb","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "65a8400e451e667e": {"kind":"object","properties":[{"name":"kind","type":"09655b34d7f3cf1e","optional":false}],"indices":[]}, "65d583e66518c523": {"kind":"object","properties":[{"name":"entries","type":"aba175fb20ffc711","optional":false},{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"incomplete","type":"dacf3d5f3e4ff619","optional":true},{"name":"kind","type":"6ebcc2dbec9f2dab","optional":false},{"name":"update","type":"dacf3d5f3e4ff619","optional":true}],"indices":[]}, @@ -953,6 +958,7 @@ "68141cbe3994a5ff": {"kind":"literal","value":"max-tokens"}, "688e853dc149f0d3": {"kind":"literal","value":"reasoning-chunks"}, "68f44f59e91c4139": {"kind":"literal","value":"user"}, + "690583582246babf": {"kind":"array","element":"613660455704fb82"}, "69081694be231d56": {"kind":"object","properties":[{"name":"data","type":"6bc8e51124d0874b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1c5a58f4599bd4a9","optional":false}],"indices":[]}, "693f4952fb5565da": {"kind":"object","properties":[{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"kind","type":"a2be0f0cb59f0d4a","optional":false}],"indices":[]}, "69d238a6e9b08d67": {"kind":"literal","value":"interrupted"}, @@ -977,7 +983,7 @@ "6f72ece8f317cd08": {"kind":"object","properties":[{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "6f9e00383836275e": {"kind":"literal","value":"snapshot"}, "70b082fe78991edf": {"kind":"literal","value":"request/context"}, - "70d2acdec1ebcfea": {"kind":"object","properties":[{"name":"data","type":"53d4f8256bc58109","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, + "70ea8e7943c5e2d7": {"kind":"object","properties":[{"name":"data","type":"65377a76c6a58f78","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1379f0b72275994d","optional":false}],"indices":[]}, "7180844e1066230c": {"kind":"literal","value":"service-stability"}, "719c51f67074f970": {"kind":"object","properties":[{"name":"kind","type":"2765e9119edf5816","optional":false},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"replayState","type":"bbc5696e3f13c758","optional":true}],"indices":[]}, "71bb3254ec8f1e49": {"kind":"object","properties":[{"name":"dt","type":"5d03ba38734809bc","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"texts","type":"93c33d9687613293","optional":false},{"name":"time0","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"688e853dc149f0d3","optional":false}],"indices":[]}, @@ -995,20 +1001,17 @@ "7a651f3b672c52fb": {"kind":"literal","value":"session/title"}, "7af85bf70d4eafce": {"kind":"object","properties":[{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7b1ce60193fa8b69": {"kind":"literal","value":"working-directory"}, - "7c203f3d4dfa6638": {"kind":"array","element":"4035903993dfee62"}, "7c6129b6961cf4b2": {"kind":"object","properties":[{"name":"messageId","type":"4cd48f3e5108bd6e","optional":false},{"name":"sessionId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "7c9f44e90a0058f4": {"kind":"object","properties":[{"name":"data","type":"5302e76315127761","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"570e850224346133","optional":false}],"indices":[]}, "7d226b9fd95b4ba2": {"kind":"union","types":["1df34a6c086866ff","cffcdc19881ab61e"]}, "7d54a5a5e629e2e7": {"kind":"object","properties":[{"name":"category","type":"3305a4bb6b6c29b4","optional":true},{"name":"text","type":"4cd48f3e5108bd6e","optional":true}],"indices":[]}, "7d65acf0a3a43a8f": {"kind":"union","types":["1f0133217eeeffd8","277b61f9c6f81704","3c2535ca0a2062f1","4e44c9118152f682","83d6916021eede8f","85f9b505b7bdff1f","fb8b6a2e53cb9cf4"]}, "7df62978a2b7de85": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"78a9be340090b735","optional":false}],"indices":[]}, - "7e9f1072c17f7aa8": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","4e97c3d85c0fc817","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "7eaef7a763ba5b94": {"kind":"object","properties":[{"name":"form","type":"16d0507cb94b4cfc","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, "7f13860ee89a2d35": {"kind":"literal","value":"session-reference"}, "7f7fd5a6b0064f59": {"kind":"object","properties":[{"name":"data","type":"5dd766ccd1702a11","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"10e386801903f9d4","optional":false}],"indices":[]}, "7f9365bae9fcb73e": {"kind":"object","properties":[{"name":"runId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"8465ece5e9d9007c","optional":false}],"indices":[]}, "7f959241c24128b4": {"kind":"object","properties":[{"name":"goalId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"b815d7b91cdd9fc6","optional":false},{"name":"revision","type":"0657d3ca5ac4e7bf","optional":false},{"name":"round","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "7ff62f6599c083ce": {"kind":"object","properties":[{"name":"maxTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"messageSeqs","type":"5d03ba38734809bc","optional":false},{"name":"messages","type":"ff2cbb53582d1827","optional":false},{"name":"reasoningEffort","type":"4cd48f3e5108bd6e","optional":true},{"name":"route","type":"07e1a58c58b593f5","optional":false},{"name":"system","type":"4cd48f3e5108bd6e","optional":false},{"name":"titleProvider","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8064c03c87c357fd": {"kind":"union","types":["12d5808fac1996b9","43e311235aa537df","4e88776858652856","814d9434e1ddf907","8e611bf46821e500","c1f0f17b6493b85d","f2da0f27c1e6add5"]}, "80677c8c8b804189": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"23af3759678a30de","optional":false}],"indices":[]}, "8067b0d232c350f1": {"kind":"literal","value":"time-context"}, @@ -1023,6 +1026,7 @@ "83d6916021eede8f": {"kind":"literal","value":"tool-removal"}, "83da72c6857601c7": {"kind":"object","properties":[{"name":"action","type":"904bf499d98be4a3","optional":false},{"name":"digest","type":"4cd48f3e5108bd6e","optional":true},{"name":"path","type":"4cd48f3e5108bd6e","optional":false},{"name":"scope","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "8465ece5e9d9007c": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","a2be0f0cb59f0d4a"]}, + "848639f8787f035b": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bf16d2e98a7f1389","optional":false},{"name":"senderSessionId","type":"4cd48f3e5108bd6e","optional":false},{"name":"stopReason","type":"866f7b2dbdc5b421","optional":true},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "84ac8c1a66ffc697": {"kind":"object","properties":[{"name":"code","type":"4cd48f3e5108bd6e","optional":false},{"name":"message","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "84bbfdce7d2eab5b": {"kind":"array","element":"33341aac09312300"}, "854db3efdfb0cd39": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"bb94f3217aa56c88","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1030,6 +1034,7 @@ "85f9b505b7bdff1f": {"kind":"literal","value":"tool-addition"}, "85fe96bf15d5c755": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"8067b0d232c350f1","optional":false}],"indices":[]}, "8620189c8e335827": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "866f7b2dbdc5b421": {"kind":"union","types":["1e0b0b66b6f0c53f","1ebc6f9ff3aa9e3a","4a561c111a78d452","68141cbe3994a5ff","a2be0f0cb59f0d4a"]}, "86d1d0e9cfebb240": {"kind":"union","types":["5f758f3de5f00c18","6487b10ba7542f3c"]}, "86e1e384495ee09a": {"kind":"object","properties":[{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "871bfd6f7bb91061": {"kind":"literal","value":"provisioning"}, @@ -1053,6 +1058,7 @@ "8f6d609794bf5afc": {"kind":"tuple","elements":[{"type":"4e88776858652856","optional":false,"rest":false}]}, "8fcb0b44a575e998": {"kind":"object","properties":[{"name":"kind","type":"a720757f704a2512","optional":false}],"indices":[]}, "904bf499d98be4a3": {"kind":"union","types":["7382c4fc95fe8c5d","b765ee31033cea46","f6cb462f808309e3"]}, + "920d87e22b7409bc": {"kind":"object","properties":[{"name":"data","type":"c2a13242efc3bea4","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"d8a476d2c6fd30df","optional":false}],"indices":[]}, "930c1cd289def119": {"kind":"union","types":["2142e391b9ccb60d","2c4c21f19f426a58","811229eedcec5797","fab35c4520883c0d"]}, "935c8d486292b13c": {"kind":"literal","value":"web_search"}, "938f7c7357bc403e": {"kind":"primitive","type":"null"}, @@ -1062,6 +1068,7 @@ "94c56aeb1564ad73": {"kind":"literal","value":"in_progress"}, "95080295115fbd83": {"kind":"object","properties":[{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"text","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"5abab3a27fd27366","optional":false}],"indices":[]}, "95a3ffccbf3c8afc": {"kind":"literal","value":"owner"}, + "95b5cf90ea1f57da": {"kind":"array","element":"e272493e3ea0b33b"}, "95c0e88fcf0bafb2": {"kind":"literal","value":"assistant"}, "9645cf93769a6dfb": {"kind":"literal","value":"user-question-reply"}, "96bfb916a727cd44": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false}],"indices":[]}, @@ -1136,6 +1143,7 @@ "b0127044ab31a702": {"kind":"object","properties":[{"name":"data","type":"ca59530d1a4f3e19","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"1986c2664009c991","optional":false}],"indices":[]}, "b062078e899958f9": {"kind":"object","properties":[{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, "b0b0eb1b16490f6b": {"kind":"object","properties":[{"name":"attachmentId","type":"4cd48f3e5108bd6e","optional":false},{"name":"bytes","type":"0657d3ca5ac4e7bf","optional":false},{"name":"height","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mediaType","type":"2f1e6a7ebace43d7","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":true},{"name":"originalDimensions","type":"0f46153645c29784","optional":true},{"name":"width","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, + "b120362fd216a16a": {"kind":"union","types":["0203243b72fc867a","022623b9ef98ec47","02b2c23f2d94b028","0ed8d1108fe7a5e1","0eeb17e1df89b1eb","11638dc58a31496f","1938c8c20f1cba7c","1e709346005826eb","1fdad624334d3548","2152caf1a71e588e","2235d957bf4b7c6c","224aa11d4d5f88c4","24ce31aa528272e5","260523b412c19ad8","28936c614bd4e7e2","290f32bfd81ff85a","297fbd8067d7815a","2aad1179f9576314","312aee2f0c42aabb","31907de956375182","3247e566f4340029","3b04790e08b35241","3b4f2c9ef2c1217e","3bdb8dc6989655b0","408927267422a7b2","45f005e6bbb76452","4991dfc415c9efeb","49dab019d00e8c26","4a9bef9f27a94252","52c6113cb0e53f34","53cf1704ae0709c7","5654685d7557ec74","58015da1c1950a66","5bb69b88517f6214","5bc66269cfaf614c","5cdbf342b7748d15","5d7a64ed6aac5551","6085488d223aeffd","6292bcbedaa97fea","65a8400e451e667e","65d583e66518c523","672c4aa2a9b736e3","6ccb6bc423fb3952","6ea608bdc8a7a63d","6f22a9d02b9a1f11","6f44f32be9bcd9a8","719c51f67074f970","7554565be416b034","756b5e23a5cb2c7f","7df62978a2b7de85","7eaef7a763ba5b94","7f959241c24128b4","80677c8c8b804189","825ca23416b1ea9d","8338ee303e2bd448","848639f8787f035b","854db3efdfb0cd39","85fe96bf15d5c755","8620189c8e335827","87b1a33564b9a4d6","88841202942c7683","8a046b52331959aa","8c1c4f72755a78ea","8c627d7a45fa406c","8f324cea3916c051","8fcb0b44a575e998","96bfb916a727cd44","99135d8a39379779","99f0e98184709ad1","9edc162949abf93b","9f65c43e340bba31","a15a19ef63cf5d1b","a16e567c8cd2c55c","a2066bd52f61218b","a3c6f1ea38e9a61d","a4afec59b75aad5d","aa2bf9e82b9fdeb9","aae343bc3d663c83","ada310bf0bdb8fed","ade057f4fdc387ae","b062078e899958f9","b2a5c8c008a31e3d","b3681c026a0b98e9","b4ec0af39fd52ed9","b613f83572fe2fbf","b6f94c17dd9bbd7c","b70f3e81fe11b7a5","b79641a9d25e7566","ba2a1a8f194b1bb2","ba4c40b4381e0dcd","baccc97ec34be6c4","c5228b7a55498d84","c55827fba7018198","cb1c8959e1726f82","cda2345a9be9d139","d85590a5675c6e2f","d9c462b9f62fdd3e","db06ef35b27a3842","dba54c09c6e7aafc","dd00cdccd0ba487b","dd5ee8762bd8a14b","dd6cc2bb2a8f2824","dde0cdf9d426768b","deaae5cd30987d4f","e7f4cbb3a57f4cbd","edbbfff1128099fd","ee577a0cc18664c9","f230c0b583e23b25","f29d63336e8266d8","f4a66f654996c093","f56c5b59ca016131","f6329fb30c7ea801","f6605d6c57933aea","f6afe41e3926f422","f7283223aacdc39b","f97f47b0eb87905e","fcf81d3c58c13593","fe1a7acdeed4d0f5"]}, "b158f3ff69237148": {"kind":"object","properties":[{"name":"contextWindow","type":"0657d3ca5ac4e7bf","optional":true},{"name":"model","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"systemPromptUpdate","type":"f311671e07bc7fa6","optional":true}],"indices":[]}, "b1664fa89f8eab49": {"kind":"array","element":"bbc5696e3f13c758"}, "b1ebd5cda47d90d9": {"kind":"object","properties":[{"name":"member","type":"6598cd6da253948d","optional":false},{"name":"teamId","type":"4cd48f3e5108bd6e","optional":false},{"name":"version","type":"a0f906856fc06f1c","optional":false}],"indices":[]}, @@ -1186,6 +1194,7 @@ "c1f0f17b6493b85d": {"kind":"object","properties":[{"name":"arguments","type":"4cd48f3e5108bd6e","optional":false},{"name":"id","type":"4cd48f3e5108bd6e","optional":false},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"type","type":"1f0133217eeeffd8","optional":false}],"indices":[]}, "c1f9e0405de6d18c": {"kind":"object","properties":[{"name":"data","type":"a6da9ad5d42ae83a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"cdca80b8dad24d4e","optional":false}],"indices":[]}, "c219efbdfd53b836": {"kind":"literal","value":"step/end"}, + "c2a13242efc3bea4": {"kind":"object","properties":[{"name":"enabled","type":"aa93010d0e521ad7","optional":false}],"indices":[]}, "c2deb7c9183735d4": {"kind":"union","types":["1ebc6f9ff3aa9e3a","2142e391b9ccb60d","b57a2889408de36e"]}, "c2f49004d9ae38a9": {"kind":"union","types":["1ebc6f9ff3aa9e3a","b57a2889408de36e","ef226a90ddac6cd8"]}, "c43b119ce579b3e9": {"kind":"literal","value":"paused"}, @@ -1195,7 +1204,6 @@ "c589e938cc0e4989": {"kind":"literal","value":"tool/call"}, "c592ce75aab73fca": {"kind":"union","types":["0657d3ca5ac4e7bf","4cd48f3e5108bd6e","529e0ccd0e34079a","72ec79127a9c0d02","938f7c7357bc403e","aa93010d0e521ad7"]}, "c748fb42c80a1200": {"kind":"array","element":"86e1e384495ee09a"}, - "c78b9097bfae83cd": {"kind":"union","types":["09f3f8dc417f2782","4035903993dfee62","4b259f7f8f0a44ad","64ef542b1bfc9883","fa87537f4a9c1e25"]}, "c8e6f48878cc2a2f": {"kind":"literal","value":"active"}, "c99e20441bf15341": {"kind":"literal","value":"feedback/record"}, "ca085dba2c877fec": {"kind":"literal","value":"sandbox/mode"}, @@ -1207,21 +1215,21 @@ "cda2345a9be9d139": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"24b10f7cdae64928","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "cdca80b8dad24d4e": {"kind":"literal","value":"tool-workflow/run-start"}, "cdee10e87dbed844": {"kind":"object","properties":[{"name":"delayMs","type":"0657d3ca5ac4e7bf","optional":false},{"name":"failure","type":"5f2117d1856a5ab4","optional":false},{"name":"maxRetries","type":"0657d3ca5ac4e7bf","optional":false},{"name":"mode","type":"3bc287e765d58dc3","optional":false},{"name":"policyKey","type":"4cd48f3e5108bd6e","optional":false},{"name":"provider","type":"4cd48f3e5108bd6e","optional":false},{"name":"retry","type":"0657d3ca5ac4e7bf","optional":false},{"name":"retryId","type":"4cd48f3e5108bd6e","optional":false},{"name":"step","type":"0657d3ca5ac4e7bf","optional":false},{"name":"turn","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, - "cdf988172b589b23": {"kind":"object","properties":[{"name":"data","type":"d4d960185026266e","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "cef171adf021a186": {"kind":"object","properties":[{"name":"arguments","type":"bbc5696e3f13c758","optional":false},{"name":"content","type":"b52f215a6cd69105","optional":false},{"name":"error","type":"a81059ca06999da1","optional":true},{"name":"isError","type":"aa93010d0e521ad7","optional":false},{"name":"meta","type":"c592ce75aab73fca","optional":true},{"name":"name","type":"4cd48f3e5108bd6e","optional":false},{"name":"parentCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"rootCallId","type":"4cd48f3e5108bd6e","optional":false},{"name":"subCallId","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "cffcdc19881ab61e": {"kind":"object","properties":[{"name":"cleared","type":"0b6d718c693fdf7f","optional":false},{"name":"clearedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"kind","type":"237c004c73b6b288","optional":false},{"name":"operation","type":"e7e154d47d5fbc53","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "d0fbb0ca1bbba059": {"kind":"literal","value":"delete"}, "d28765f87d6b6ab4": {"kind":"literal","value":"one-shot"}, "d4b739491689cc6b": {"kind":"object","properties":[{"name":"block","type":"8064c03c87c357fd","optional":false},{"name":"index","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"eefbea819caeeea9","optional":false}],"indices":[]}, "d4c799e3d51cae10": {"kind":"literal","value":"working-directory/change"}, - "d4d960185026266e": {"kind":"object","properties":[{"name":"inserted","type":"7c203f3d4dfa6638","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, "d4e4d575abeb4b72": {"kind":"literal","value":"canceled"}, "d595ec73b32b016a": {"kind":"object","properties":[{"name":"data","type":"a40d12070f6f4a12","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"b57ae1b37e52bee0","optional":false}],"indices":[]}, "d63b8b8ffad9c02f": {"kind":"object","properties":[{"name":"data","type":"8bb8c3751a6ce64b","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"47e5d84f1a93450a","optional":false}],"indices":[]}, "d66e11dddf5dfa3c": {"kind":"object","properties":[{"name":"data","type":"cef171adf021a186","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"fb5e0517050d2c6d","optional":false}],"indices":[]}, "d6e654b88b584fcd": {"kind":"literal","value":"fallback"}, "d85590a5675c6e2f": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false}],"indices":[]}, + "d8a476d2c6fd30df": {"kind":"literal","value":"team/mode"}, "d9196086e41e4dd5": {"kind":"literal","value":"complete"}, + "d95c624eddecdacf": {"kind":"object","properties":[{"name":"data","type":"601327bedca5f98a","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"587bb11da730e06a","optional":false}],"indices":[]}, "d9c462b9f62fdd3e": {"kind":"object","properties":[{"name":"callId","type":"4cd48f3e5108bd6e","optional":false},{"name":"kind","type":"a13d8f379222ece8","optional":false}],"indices":[]}, "d9edf1862e4471d6": {"kind":"literal","value":"schedule"}, "da60ed58a8fa23d7": {"kind":"object","properties":[{"name":"cwd","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, @@ -1231,10 +1239,12 @@ "db874d463b0fdec7": {"kind":"object","properties":[{"name":"data","type":"3e4e7683b0192c9f","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"e2d0367e5716bc18","optional":false}],"indices":[]}, "dba54c09c6e7aafc": {"kind":"object","properties":[{"name":"kind","type":"4edec1b770e8ec4c","optional":false}],"indices":[]}, "dbdafceb9a900183": {"kind":"literal","value":"hook"}, + "dcb07e04496586d2": {"kind":"object","properties":[{"name":"data","type":"613660455704fb82","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"sourceEventSeqs","type":"5d03ba38734809bc","optional":true},{"name":"surfaceOp","type":"335e242de1fcc17b","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"de25352d2dc41963","optional":false}],"indices":[]}, "dd00cdccd0ba487b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "dd2780e7e0768433": {"kind":"literal","value":"fresh"}, "dd5ee8762bd8a14b": {"kind":"object","properties":[{"name":"form","type":"aec150797b655c5f","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, "dd6cc2bb2a8f2824": {"kind":"object","properties":[{"name":"form","type":"383cf73ae4748e5b","optional":false},{"name":"kind","type":"99e7e7d7fed55ae6","optional":false}],"indices":[]}, + "ddce54a3873dc571": {"kind":"object","properties":[{"name":"data","type":"e1880c67e735e228","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"829a2fb3be6efd4b","optional":false}],"indices":[]}, "dde0cdf9d426768b": {"kind":"object","properties":[{"name":"kind","type":"05205074b24d8340","optional":false},{"name":"through","type":"0657d3ca5ac4e7bf","optional":false}],"indices":[]}, "de25352d2dc41963": {"kind":"literal","value":"user/message"}, "de30b1fa12c75f81": {"kind":"literal","value":"image/gif"}, @@ -1244,6 +1254,8 @@ "df843efd87c78cf8": {"kind":"array","element":"4f91f187baeabef5"}, "dfce5db4aa42b7eb": {"kind":"union","types":["871bfd6f7bb91061","b57a2889408de36e","c8e6f48878cc2a2f"]}, "e0a787e6ec76c7c9": {"kind":"object","properties":[{"name":"data","type":"ef11a3b7fa2033ae","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"c219efbdfd53b836","optional":false}],"indices":[]}, + "e1880c67e735e228": {"kind":"object","properties":[{"name":"inserted","type":"690583582246babf","optional":false},{"name":"outcome","type":"d4e4d575abeb4b72","optional":true},{"name":"removedCount","type":"0657d3ca5ac4e7bf","optional":true},{"name":"start","type":"0657d3ca5ac4e7bf","optional":false},{"name":"target","type":"96f4a9c81fc0f940","optional":false}],"indices":[]}, + "e272493e3ea0b33b": {"kind":"union","types":["01c161379a3c3394","09f3f8dc417f2782","4b259f7f8f0a44ad","613660455704fb82","fa87537f4a9c1e25"]}, "e2ad8f5bcd813f26": {"kind":"object","properties":[{"name":"childCreatedAt","type":"0657d3ca5ac4e7bf","optional":false},{"name":"childId","type":"4cd48f3e5108bd6e","optional":false},{"name":"label","type":"4cd48f3e5108bd6e","optional":false},{"name":"mode","type":"e5d3828df1ec3e2a","optional":false},{"name":"version","type":"f88f1b7b6bb733f6","optional":false}],"indices":[]}, "e2d0367e5716bc18": {"kind":"literal","value":"compaction/start"}, "e2f9a41e0989f54e": {"kind":"object","properties":[{"name":"data","type":"9947aa5938fe19a8","optional":false},{"name":"ignorable","type":"dacf3d5f3e4ff619","optional":true},{"name":"seq","type":"0657d3ca5ac4e7bf","optional":false},{"name":"time","type":"0657d3ca5ac4e7bf","optional":false},{"name":"type","type":"ef4f00923b9edbae","optional":false}],"indices":[]}, @@ -1320,7 +1332,6 @@ "fe1a7acdeed4d0f5": {"kind":"object","properties":[{"name":"form","type":"e8de0f457fed5b9f","optional":false},{"name":"kind","type":"1f1385dbc3e875c0","optional":false},{"name":"summary","type":"4cd48f3e5108bd6e","optional":false}],"indices":[]}, "fe792a6cd4304945": {"kind":"literal","value":"tool-workflow/run-end"}, "fec5442b9ee4122e": {"kind":"object","properties":[{"name":"cacheReadTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"cacheWriteTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"inputTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"outputTokens","type":"0657d3ca5ac4e7bf","optional":false},{"name":"reasoningTokens","type":"0657d3ca5ac4e7bf","optional":true},{"name":"totalTokens","type":"0657d3ca5ac4e7bf","optional":true}],"indices":[]}, - "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"}, - "ff2cbb53582d1827": {"kind":"array","element":"c78b9097bfae83cd"} + "fee1caf8ab6a65c1": {"kind":"literal","value":"instructions"} } } diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 069f78cc33..9bd04642b1 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: 81b2c48d922de5a9 - zh: cf0db50b34d2feb3 + en: e95b837b8f47eac8 + zh: bd69de97c80fe2ae diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 86e30a5519..7d66caa4dd 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -51,7 +51,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | | [voice-input.md](voice-input.md) | experimental named recognizers, transient audio and revision-guarded draft insertion | | [translation.md](translation.md) | experimental anonymous text translation and explicit provider requests | -| [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, direct peer inbox delivery, and shared task DAG | +| [agent-team.md](agent-team.md) | Agent Teams: shared task DAG and task ownership for a subagent tree | | [liveshare.md](liveshare.md) | the opt-in LiveShare bundle: nested share modes, a fork's ceiling and durability, enforcement installation, and the constraint a shared Session's externally driven turns run under | | [claude-code-mods.md](claude-code-mods.md) | Claude Code mods through the experimental bridge: every event, `$` member, chain rule, drawing feature, and example mod that differs from Claude Code | | [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 289a6a71f3..7358fe0203 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -51,7 +51,7 @@ | [subagent.md](subagent.zh.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 | | [voice-input.md](voice-input.zh.md) | 实验性具名语音识别、临时音频和受版本保护的草稿插入 | | [translation.md](translation.zh.md) | 实验性免登录文本翻译与显式 Provider 请求 | -| [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、直接 peer inbox 投递 与共享任务 DAG | +| [agent-team.md](agent-team.zh.md) | Agent Teams:子代理树共享的任务 DAG 与任务归属 | | [liveshare.md](liveshare.zh.md) | 可选的 LiveShare bundle:嵌套分享模式、fork 的上限与持久性、上限安装,以及共享会话外部驱动轮次所受的约束 | | [claude-code-mods.md](claude-code-mods.zh.md) | 经实验性桥接运行的 Claude Code 模组:与 Claude Code 不同的每个事件、`$` 成员、链规则、绘制特性与示例模组 | | [web.md](web.zh.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` | diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index a4c599562f..435e1b6bbb 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.i18n.yaml @@ -3,20 +3,20 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md /agent-teams: - en: f571e223220bb616 - zh: dd7e37fdf359eeae -/agent-teams/identity-and-roster: - en: df72d9409b89714d - zh: 982934dd18864ca2 + en: 152195aeb3b5dea1 + zh: 1cf6b3b36bd6f02c +/agent-teams/identity-and-historical-roster: + en: 3a574287e378ecf9 + zh: 818667289271c1af /agent-teams/messages-and-historical-mailbox: - en: 2aed7dd369f47747 - zh: 4f080e50c584ad37 + en: e50b723dfffd6758 + zh: 6e4d5891b5d913fc /agent-teams/shared-task-dag: - en: 4515005d07e4d2a3 - zh: 26811b31f69009d6 + en: c70f0fd199fc7db5 + zh: c6b77cb5a5cfd1c2 /agent-teams/web-projection: - en: 6af99eff8036597a - zh: f172f994e01f12c1 + en: 059d4e5d6a9ddc71 + zh: cb3fd92096144e19 /agent-teams/replay: - en: fe32f71c6d799ebc - zh: aa2b6ccddba8cfd5 + en: 448d32856f87b067 + zh: 7fafd96314c7e2c5 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index c5c6a0f775..49b27ea176 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -2,14 +2,14 @@ English | [中文](agent-team.zh.md) -Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the durable and client-visible forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). +Types shared by the shared task board, model tools, and host adapters. The [task-board wait decision](../../.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.md) owns coordination semantics; this page records durable and client-visible forms from [`packages/team/agent-team/src/types.ts`](../../packages/team/agent-team/src/types.ts). -## Identity and roster +## Identity and historical roster -`TeamId` is the root `SessionId` under a distinct [brand](core.md#branded-ids). `TeamTaskId` is allocated as `task-`. New sends return the target inbox `MessageId`; `TeamMessageId` is retained only for historical mailbox records. Teammate names are immutable labels. +`TeamId` brands the root `SessionId`; `TeamTaskId` uses `task-`. `TeamMessageId` and roster records remain for historical decoding. The subagent runtime resolves the tree and discovers eligible task owners at arbitrary depth; historical roster entries grant no authority. ```ts type-equiv -/** Whole durable value written on every teammate lifecycle change. */ +/** Historical roster value written by earlier Team builds on every teammate lifecycle change. */ interface TeamMemberSnapshot { readonly id: SessionId readonly name: string @@ -21,11 +21,11 @@ interface TeamMemberSnapshot { } ``` -Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Roster `running`/`inactive` status is derived separately and never rewrites this record. +Historical records retain their recorded `provisioning`, `active`, or `failed` phase. Current turn activity belongs to ordinary subagent discovery and Session status, independently of task completion. ## Messages and historical mailbox -New sends return inbox acceptance or reject. The target stores the existing `agent-message` source with the real sender Session id and a sender-name content prefix. Team has no new-message outbox or resend deduplication. The [package contract](../../packages/experimental/agent-team/README.md#understand-the-implementation) defines persistence. The following mailbox types remain readable so historical Lead logs still replay; Team does not deliver their pending records. +Members exchange ordinary [subagent messages](subagent.md), retaining the real sender Session id. Task changes do not end inbox waits, add inbox messages, or start idle agents. Send an explicit message when an agent needs to act on a task update. The following historical mailbox types remain readable; Team never delivers their pending records. ```ts type-equiv /** One peer message recorded by the historical Team mailbox. */ @@ -38,8 +38,6 @@ interface TeamMessageSnapshot { } ``` -Every message attempts Steer delivery. A running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Scheduling is not stored in the durable record because callers cannot select another mode. - Historical `team-message` sources retain the old message id and sender attribution; the client uses them for the message title and icon. ```ts type-equiv @@ -71,25 +69,15 @@ interface TeamTaskSnapshot { } ``` -`pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot. +`pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views expose `ownerId`, readiness, and advisory write-scope overlap without changing the durable snapshot. ## Web projection -The Lead Session publishes `SessionProjectionMap.agentTeam` with durable roster rows and non-deleted task views. `failure` reports a rejected persisted record beside the last valid state. Member activity comes from Session status; model labels come from each member's `modelSelection` projection. +`agentTeam.enabled` exposes the root Session’s saved selection. New Sessions default to false; `team/mode` records explicit selections. Historical tasks imply enabled until an explicit selection. Only the root can call `setEnabled`; descendants share that mode. The Web menu calls the same method through `remote.agentTeams.setEnabled`, with the root Session id resolved to its live Agent by the Gateway. Mode writes take effect before awaiting persistence. Tools resolve availability at step preparation; prompt providers read the selection when evaluated. Task mutations check the current mode when their queued operation starts; admitted mutations may finish. Mode changes do not end inbox waits. A failed flush leaves the appended selection active; retrying the same selection flushes it without a duplicate event. -```ts type-equiv -/** One durable roster row published through the `agentTeam` Session projection. */ -interface TeamMemberProjection { - readonly id: SessionId - readonly name: string - readonly role: 'lead' | 'teammate' - /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ - readonly phase: TeamMemberPhase - readonly error?: string -} -``` +The root Session publishes non-deleted task views through `SessionProjectionMap.agentTeam`. `failure` reports a rejected persisted task or mode record beside the last valid state. Historical roster and mailbox records are not projected. The task panel identifies every owner by its Session id. ```ts type-equiv /** Runtime-enriched task view returned to tools and hosts. */ @@ -101,7 +89,7 @@ interface TeamTaskView { readonly status: TeamTaskStatus readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] - readonly ownerName?: string + readonly ownerId?: SessionId readonly ready: boolean readonly writeScopeWarnings: string[] } @@ -111,10 +99,10 @@ interface TeamTaskView { /** * Durable Team state published to browser clients through the Lead Session's * `agentTeam` projection. `failure` names the first rejected persisted Team - * record; members and tasks then stay at the last valid state. + * record; tasks then stay at the last valid state. */ interface TeamProjection { - readonly members: TeamMemberProjection[] + readonly enabled: boolean readonly tasks: TeamTaskView[] readonly failure?: string } @@ -122,7 +110,7 @@ interface TeamProjection { ## Replay -The `agentTeam` Session projection replays one root Session into the roster, task board, and historical queued-minus-delivered mailbox. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail is not exposed to callers. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. +The `agentTeam` projection replays task records selected by `TeamId` and the Session’s own mode selections. Subagent discovery owns current tree membership and ignores inherited catalog authority. Session event `seq` and `time` own ordering and timing. Committed durable generations are preserved. The [package README](../../packages/team/agent-team/README.md) defines task operations and limits. @@ -140,42 +128,35 @@ Agent Teams service backed by the exact live Lead Session log. ```ts cordis-catalog /** - * Resolve one exact live Agent's Team role. - * @param agent - exact live Agent used as the authority credential. - * @returns its root, Team identity, role, and model-facing name. + * Read the Team selection shared by the caller's subagent tree. + * @param caller - exact live root or descendant. + * @returns whether Team tools and guidance are enabled. */ -membership(agent: Agent): TeamMembership +isEnabled(caller: Agent): boolean /** - * List the runtime-enriched roster visible to one Team member. - * @param agent - exact live Team member. - * @returns Lead and teammate rows in creation order. + * Save a root Session's Team selection without stopping children or deleting tasks. + * The selection takes effect before awaiting persistence. Tools resolve availability at + * step preparation; prompt providers read the selection when evaluated. Task mutations + * check the current mode when their queued operation starts; admitted operations finish. + * @param agent - exact live root whose tree receives the selection. + * @param enabled - selected Team mode. + * @param signal - cancellation checked before changing the selection. + * @returns fulfillment after the selection flushes. + * @throws when called by a descendant or persistence fails. An appended selection remains active; + * retrying the same selection flushes without a duplicate event. */ -listMembers(agent: Agent): TeamMemberView[] - -/** - * Create one named, continuable direct child of the Team Lead. - * @param caller - exact live Lead Agent. - * @param request - immutable name, description, prompt, context mode, provider, and cancellation. - * @returns the active roster row. - */ -async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise - -/** - * Steer one peer message into the target inbox or reject the attempt. - * @param caller - exact live sending Team member. - * @param request - target name, content, and cancellation before acceptance. - * @returns accepted inbox identity; acceptance follows normal Agent persistence and does not await model processing. - */ -async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise +@Remote async setEnabled(agent: Agent, enabled: boolean, signal?: AbortSignal): Promise /** * Create one unowned pending task in the Team Lead log. * @param caller - exact live Team member creating the task. * @param request - task text, blockers, and advisory write scopes. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the revision-one task view. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise +async createTask(caller: Agent, request: CreateTeamTaskRequest, signal?: AbortSignal): Promise /** * Return one task, including a deleted tombstone. @@ -196,36 +177,14 @@ listTasks(caller: Agent): TeamTaskView[] * Compare-and-set one authorized task transition. * @param caller - exact live Team member authorizing the mutation. * @param request - task identity, expected revision, action, and action fields. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the committed next task revision. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise - -/** - * Wait for the next Team-domain or member-status change. - * @param caller - exact live Team member waiting for activity. - * @param timeoutMs - bounded wait duration from ten seconds through one hour. - * @param signal - caller cancellation for the wait only. - * @returns one observed change or a timeout result. - */ -async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise - -/** - * Interrupt one live teammate turn without clearing its pending inbox. - * @param caller - exact live Lead Agent. - * @param targetName - durable teammate name. - * @returns the target status sampled before cancellation. - */ -interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } - -/** - * Resolve a caller without throwing, used by scoped-tool installation and observers. - * @param agent - candidate exact live Agent. - * @returns Team membership, or undefined for non-Team subagents and stale identities. - */ -tryMembership(agent: Agent): TeamMembership | undefined +async updateTask(caller: Agent, request: UpdateTeamTaskRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.md) -Source: [`packages/experimental/agent-team/src/index.ts`](../../packages/experimental/agent-team/src/index.ts) +Source: [`packages/team/agent-team/src/index.ts`](../../packages/team/agent-team/src/index.ts) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 2e3c0b040e..fb52d49be4 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -2,14 +2,14 @@ [English](agent-team.md) | 中文 -实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的持久与客户端可见形式。 +共享任务板、模型工具和 host 适配器共享的类型。[任务板等待决策](../../.agents/notes/implemented/architecture/2026-10-19-team-task-board-inbox.zh.md)负责协调语义;本页记录 [`packages/team/agent-team/src/types.ts`](../../packages/team/agent-team/src/types.ts) 中的持久化和客户端可见形式。 -## 身份与 roster +## 身份与历史名册 -`TeamId` 是使用独立[品牌类型](core.zh.md#branded-ids)的 root `SessionId`。`TeamTaskId` 按 `task-` 分配。新发送返回目标 inbox 的 `MessageId`;`TeamMessageId` 仅供历史 mailbox 记录保留。teammate 名称是不可变标签。 +`TeamId` 品牌化根 `SessionId`;`TeamTaskId` 使用 `task-`。`TeamMessageId` 和名册记录为历史解码保留。subagent 运行时解析树,并发现任意深度的有效任务所有者;历史名册条目不授予权限。 ```ts type-equiv -/** Whole durable value written on every teammate lifecycle change. */ +/** Historical roster value written by earlier Team builds on every teammate lifecycle change. */ interface TeamMemberSnapshot { readonly id: SessionId readonly name: string @@ -21,11 +21,11 @@ interface TeamMemberSnapshot { } ``` -每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。roster 的 `running`/`inactive` 状态单独派生,绝不会重写该记录。 +历史记录保留其记录的 `provisioning`、`active` 或 `failed` 阶段。当前轮次活动属于普通 subagent 发现和 Session 状态,与任务完成相互独立。 ## 消息与历史 mailbox -新发送返回 inbox 接收结果或拒绝。目标保存现有 `agent-message` source,包含真实发送者 Session id 和发送者名称正文前缀。Team 不为新消息提供 outbox 或重发去重。[包约定](../../packages/experimental/agent-team/README.zh.md#understand-the-implementation)定义持久化。以下 mailbox 类型继续可读,以便历史 Lead 日志仍能回放;Team 不投递其中的 pending 记录。 +成员交换普通 [subagent 消息](subagent.zh.md),保留真实发送方 Session id。任务变更不会结束收件箱等待、添加收件箱消息或启动空闲 agent。需要 agent 处理任务更新时,发送明确消息。下列历史邮箱类型保持可读;Team 绝不投递其待处理记录。 ```ts type-equiv /** One peer message recorded by the historical Team mailbox. */ @@ -38,8 +38,6 @@ interface TeamMessageSnapshot { } ``` -每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。 - 历史 `team-message` source 保留旧消息 id 与发送者归属;客户端用它们展示消息标题和图标。 ```ts type-equiv @@ -71,25 +69,15 @@ interface TeamTaskSnapshot { } ``` -`pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。 +`pending` 表示未开始或已释放,`in_progress` 携带负责人,`completed` 满足依赖,`deleted` 是保留的墓碑记录。视图提供 `ownerId`、就绪性和写入范围重叠提示,不改变持久快照。 ## Web 投影 -Lead Session 通过 `SessionProjectionMap.agentTeam` 发布持久 roster 行与未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久记录。成员活动来自 Session 状态;模型标签来自各成员的 `modelSelection` 投影。 +`agentTeam.enabled` 提供根 Session 保存的选择。新 Session 默认为 false;`team/mode` 记录显式选择。历史任务在显式选择前视为开启。仅根可调用 `setEnabled`;后代共享该模式。Web 菜单通过 `remote.agentTeams.setEnabled` 调用同一方法,Gateway 将根 Session id 解析为其存活 Agent。模式写入先立即生效,再等待刷盘。工具在步骤准备时确定可用性;提示词提供方在求值时读取选择。任务修改在排队操作开始时检查当前模式;已获准的修改可继续完成。模式变更不会结束收件箱等待。刷盘失败时已追加的选择仍然生效;重试相同选择会再次刷盘,不重复追加事件。 -```ts type-equiv -/** One durable roster row published through the `agentTeam` Session projection. */ -interface TeamMemberProjection { - readonly id: SessionId - readonly name: string - readonly role: 'lead' | 'teammate' - /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ - readonly phase: TeamMemberPhase - readonly error?: string -} -``` +根 Session 通过 `SessionProjectionMap.agentTeam` 发布未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久化任务或模式记录。历史名册与邮箱记录不参与投影。任务面板以 Session id 标识每个负责人。 ```ts type-equiv /** Runtime-enriched task view returned to tools and hosts. */ @@ -101,7 +89,7 @@ interface TeamTaskView { readonly status: TeamTaskStatus readonly blockedBy: TeamTaskId[] readonly writeScopes: string[] - readonly ownerName?: string + readonly ownerId?: SessionId readonly ready: boolean readonly writeScopeWarnings: string[] } @@ -111,10 +99,10 @@ interface TeamTaskView { /** * Durable Team state published to browser clients through the Lead Session's * `agentTeam` projection. `failure` names the first rejected persisted Team - * record; members and tasks then stay at the last valid state. + * record; tasks then stay at the last valid state. */ interface TeamProjection { - readonly members: TeamMemberProjection[] + readonly enabled: boolean readonly tasks: TeamTaskView[] readonly failure?: string } @@ -122,7 +110,7 @@ interface TeamProjection { ## 回放 -`agentTeam` Session 投影把一个 Root Session 回放成roster、任务板与历史 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件不对调用方公开。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 +`agentTeam` 投影按 `TeamId` 重放任务记录,并重放 Session 自身的模式选择。subagent 发现负责当前树成员关系,不将继承目录作为权限来源。Session 事件的 `seq` 和 `time` 负责排序和时间。已提交的持久化代次保持不变。[包 README](../../packages/team/agent-team/README.zh.md)定义任务操作和限制。 @@ -140,42 +128,35 @@ Agent Teams service backed by the exact live Lead Session log. ```ts cordis-catalog /** - * Resolve one exact live Agent's Team role. - * @param agent - exact live Agent used as the authority credential. - * @returns its root, Team identity, role, and model-facing name. + * Read the Team selection shared by the caller's subagent tree. + * @param caller - exact live root or descendant. + * @returns whether Team tools and guidance are enabled. */ -membership(agent: Agent): TeamMembership +isEnabled(caller: Agent): boolean /** - * List the runtime-enriched roster visible to one Team member. - * @param agent - exact live Team member. - * @returns Lead and teammate rows in creation order. + * Save a root Session's Team selection without stopping children or deleting tasks. + * The selection takes effect before awaiting persistence. Tools resolve availability at + * step preparation; prompt providers read the selection when evaluated. Task mutations + * check the current mode when their queued operation starts; admitted operations finish. + * @param agent - exact live root whose tree receives the selection. + * @param enabled - selected Team mode. + * @param signal - cancellation checked before changing the selection. + * @returns fulfillment after the selection flushes. + * @throws when called by a descendant or persistence fails. An appended selection remains active; + * retrying the same selection flushes without a duplicate event. */ -listMembers(agent: Agent): TeamMemberView[] - -/** - * Create one named, continuable direct child of the Team Lead. - * @param caller - exact live Lead Agent. - * @param request - immutable name, description, prompt, context mode, provider, and cancellation. - * @returns the active roster row. - */ -async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise - -/** - * Steer one peer message into the target inbox or reject the attempt. - * @param caller - exact live sending Team member. - * @param request - target name, content, and cancellation before acceptance. - * @returns accepted inbox identity; acceptance follows normal Agent persistence and does not await model processing. - */ -async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise +@Remote async setEnabled(agent: Agent, enabled: boolean, signal?: AbortSignal): Promise /** * Create one unowned pending task in the Team Lead log. * @param caller - exact live Team member creating the task. * @param request - task text, blockers, and advisory write scopes. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the revision-one task view. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async createTask(caller: Agent, request: CreateTeamTaskRequest): Promise +async createTask(caller: Agent, request: CreateTeamTaskRequest, signal?: AbortSignal): Promise /** * Return one task, including a deleted tombstone. @@ -196,36 +177,14 @@ listTasks(caller: Agent): TeamTaskView[] * Compare-and-set one authorized task transition. * @param caller - exact live Team member authorizing the mutation. * @param request - task identity, expected revision, action, and action fields. + * @param signal - cancellation before the task event is appended; omitted means no cancellation. * @returns the committed next task revision. + * @throws TEAM_TASK_PERSISTENCE_UNCONFIRMED when the task is visible but durability is unknown; read it before retrying. */ -async updateTask(caller: Agent, request: UpdateTeamTaskRequest): Promise - -/** - * Wait for the next Team-domain or member-status change. - * @param caller - exact live Team member waiting for activity. - * @param timeoutMs - bounded wait duration from ten seconds through one hour. - * @param signal - caller cancellation for the wait only. - * @returns one observed change or a timeout result. - */ -async waitForChange(caller: Agent, timeoutMs: number, signal: AbortSignal): Promise - -/** - * Interrupt one live teammate turn without clearing its pending inbox. - * @param caller - exact live Lead Agent. - * @param targetName - durable teammate name. - * @returns the target status sampled before cancellation. - */ -interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } - -/** - * Resolve a caller without throwing, used by scoped-tool installation and observers. - * @param agent - candidate exact live Agent. - * @returns Team membership, or undefined for non-Team subagents and stale identities. - */ -tryMembership(agent: Agent): TeamMembership | undefined +async updateTask(caller: Agent, request: UpdateTeamTaskRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.zh.md) -Source: [`packages/experimental/agent-team/src/index.ts`](../../packages/experimental/agent-team/src/index.ts) +Source: [`packages/team/agent-team/src/index.ts`](../../packages/team/agent-team/src/index.ts) diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a2b168f3fd..363584a5fd 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -9,8 +9,8 @@ en: c90cb23528ddb6c8 zh: 62724b7255b586b6 /core/creation-and-ownership: - en: e516e14228c6bd35 - zh: dba25d7da1d5a44e + en: d71c669c1a36bf52 + zh: 9b335f90c84a894e /core/the-agent-handle: en: 6ee15fec81bc0686 zh: b6f3fcd48823ea75 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 48ff2ce633..40264ec898 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,9 +46,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: an optional live `parentAgent`, session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `parentAgent`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` after Session store entry and completed `session/created`, before Agent registration: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal unwinds the transaction, removes the Session, and preserves restored pending input and already stored events. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` after Session store entry and completed `session/created`, before Agent registration: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal unwinds the transaction, removes the Session, and preserves restored pending input and already stored events. -`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator sets `options.parentAgent`; the registry passes the options and caller Context to the factory without deriving one from the other. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The registry passes creation options and the caller Context to the factory; the caller Context owns the returned handle's lifetime. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -764,7 +764,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, optional live parent, session seed/metadata, and agent options. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -773,7 +773,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, optional live parent, configuration, and setup. + * @param options - persisted identity, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -785,8 +785,7 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). The entry is a runtime root; factory-backed - * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent. + * requires passing the carrier). Await registration before using the agent. * @param agent - the already-constructed agent to record in the store. * @returns the awaitable Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -806,15 +805,12 @@ register(agent: Agent): ReturnType * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - explicitly supplied live runtime owner, or - * undefined for a top-level runtime root. This is runtime ownership, not - * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * `agent/created` listener, removal and disposal wait until the serial * creation dispatch settles. */ -enter(agent: Agent, owner: Agent | undefined): () => void +enter(agent: Agent): () => void /** * Announce an agent previously inserted with {@link enter}. @@ -835,29 +831,11 @@ async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): */ get(id: SessionId): Agent | undefined -/** - * Test whether a live agent was created through one exact parent agent's - * scoped context. Runtime ownership is independent of durable session - * lineage and remains unambiguous when unrelated providers reuse an id. - * @param id - the candidate child agent's shared agent/session id. - * @param owner - the expected runtime creator agent. - * @returns true only while the exact child entry is live under that owner. - */ -isOwnedBy(id: SessionId, owner: Agent): boolean - /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. */ list(): Agent[] - -/** - * All live top-level agents in registration order. A top-level agent was - * created without an owning agent context; durable session lineage does not - * affect this runtime relation, so a resumed fork may still be a root. - * @returns a fresh array; mutating it does not affect the registry. - */ -roots(): Agent[] ``` Types: [Session](session.md) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 19acf51b51..ff1d3b579e 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,9 +48,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:可选的存活 `parentAgent`、会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`parentAgent`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在 Session 入库且 `session/created` 完成后、Agent 注册前接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会撤销事务并移除 Session,同时保留恢复出的待处理输入及已存储事件。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在 Session 入库且 `session/created` 完成后、Agent 注册前接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会撤销事务并移除 Session,同时保留恢复出的待处理输入及已存储事件。 -`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方设置 `options.parentAgent`;注册表把 options 与调用方 Context 传给工厂,不从其中一项推导另一项。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。注册表把创建选项和调用方 Context 传给工厂;调用方 Context 拥有返回句柄的生命周期。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -776,7 +776,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, optional live parent, session seed/metadata, and agent options. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -785,7 +785,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, optional live parent, configuration, and setup. + * @param options - persisted identity, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -797,8 +797,7 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). The entry is a runtime root; factory-backed - * creation uses `options.parentAgent` for child ownership. Await the registration before using the agent. + * requires passing the carrier). Await registration before using the agent. * @param agent - the already-constructed agent to record in the store. * @returns the awaitable Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -818,15 +817,12 @@ register(agent: Agent): ReturnType * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - explicitly supplied live runtime owner, or - * undefined for a top-level runtime root. This is runtime ownership, not - * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * `agent/created` listener, removal and disposal wait until the serial * creation dispatch settles. */ -enter(agent: Agent, owner: Agent | undefined): () => void +enter(agent: Agent): () => void /** * Announce an agent previously inserted with {@link enter}. @@ -847,29 +843,11 @@ async announce(agent: Agent, source: SessionStartSource, signal?: AbortSignal): */ get(id: SessionId): Agent | undefined -/** - * Test whether a live agent was created through one exact parent agent's - * scoped context. Runtime ownership is independent of durable session - * lineage and remains unambiguous when unrelated providers reuse an id. - * @param id - the candidate child agent's shared agent/session id. - * @param owner - the expected runtime creator agent. - * @returns true only while the exact child entry is live under that owner. - */ -isOwnedBy(id: SessionId, owner: Agent): boolean - /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. */ list(): Agent[] - -/** - * All live top-level agents in registration order. A top-level agent was - * created without an owning agent context; durable session lineage does not - * affect this runtime relation, so a resumed fork may still be a root. - * @returns a fresh array; mutating it does not affect the registry. - */ -roots(): Agent[] ``` Types: [Session](session.zh.md) diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index bad9827407..06c51858d4 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.i18n.yaml @@ -9,8 +9,8 @@ en: 0083032b9d19467d zh: 15d7a7520bac094e /user-credentials/resolution: - en: baaab4bca76bea6b - zh: 54ccdc03ffd44f63 + en: 0583aeab49d80a4b + zh: 1ffd60d7e79f67cf /user-credentials/description: en: f7b09588c0f34952 zh: 1b93d673c5bf6a80 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index 2a084fb29f..b4ffca123b 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -20,6 +20,8 @@ type CredentialRef = Branded<'CredentialRef'> `resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism. +`readStored(ref)` returns only the provider-managed value for editing, or `undefined` when none is stored. It never reads environment fallbacks. The Remote editor operation restricts this read to the owner. + ```ts type-equiv /** One resolved credential value and the source layer that supplied it. */ interface ResolvedCredential { @@ -152,6 +154,13 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not */ abstract resolve(ref: CredentialRef): Promise +/** + * Read the provider-managed value without consulting environment fallbacks. + * @param ref - the reference to read. + * @returns the stored secret, or undefined when no non-empty value is stored. + */ +abstract readStored(ref: CredentialRef): Promise + /** * Describe one reference for configuration surfaces without exposing the * value. @@ -228,7 +237,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ### `ctx.credentialsController` — `CredentialsController` -Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor. ```ts cordis-catalog /** @@ -243,8 +252,15 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie @Remote async describe(refs: string[]): Promise> /** - * Store one value from a configuration surface. The value crosses the wire in - * this direction only: no read path returns it. + * Read one stored value for a credential editor, excluding environment fallbacks. + * @param ref - reference name to read. + * @returns the stored secret, or undefined when the provider stores none. + * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. + */ +@Remote async readStored(ref: string): Promise + +/** + * Store one value from a configuration surface. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index 1e9221699a..d4784832ca 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -20,6 +20,8 @@ type CredentialRef = Branded<'CredentialRef'> `resolve(ref)` 返回值及提供该值的来源层(由提供方定义);未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这种按操作进行的读取正是热更新机制。 +`readStored(ref)` 仅返回用于编辑的提供方托管值;未存储时返回 `undefined`。它不读取环境后备值。编辑器的 Remote 操作仅允许所有者执行此读取。 + ```ts type-equiv /** One resolved credential value and the source layer that supplied it. */ interface ResolvedCredential { @@ -152,6 +154,13 @@ A CredentialKey answers "what credential does this plugin hold for this id". Not */ abstract resolve(ref: CredentialRef): Promise +/** + * Read the provider-managed value without consulting environment fallbacks. + * @param ref - the reference to read. + * @returns the stored secret, or undefined when no non-empty value is stored. + */ +abstract readStored(ref: CredentialRef): Promise + /** * Describe one reference for configuration surfaces without exposing the * value. @@ -228,7 +237,7 @@ Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credent ### `ctx.credentialsController` — `CredentialsController` -Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Secret values cross in one direction only — no method here returns one. +Host service backing the generated `ctx.remote.credentials` namespace. It carries every wire obligation the credential seam itself does not: the batch fan-out bound, the field-by-field view projection, the reference-grammar guard, and the refusal mapping. Metadata reads exclude secrets; readStored returns only a provider-managed value for an explicitly opened editor. ```ts cordis-catalog /** @@ -243,8 +252,15 @@ Host service backing the generated `ctx.remote.credentials` namespace. It carrie @Remote async describe(refs: string[]): Promise> /** - * Store one value from a configuration surface. The value crosses the wire in - * this direction only: no read path returns it. + * Read one stored value for a credential editor, excluding environment fallbacks. + * @param ref - reference name to read. + * @returns the stored secret, or undefined when the provider stores none. + * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. + */ +@Remote async readStored(ref: string): Promise + +/** + * Store one value from a configuration surface. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/docs/subsystems/liveshare.i18n.yaml b/docs/subsystems/liveshare.i18n.yaml index e1ba0426b4..e6afd9ab2f 100644 --- a/docs/subsystems/liveshare.i18n.yaml +++ b/docs/subsystems/liveshare.i18n.yaml @@ -9,8 +9,8 @@ en: f6144f8e81359a9c zh: 1924869ac7475b59 /liveshare/share-modes: - en: 985cb7a5c20c1cbd - zh: a5f5372dddfefaa3 + en: 08ce9269b4556bdd + zh: 7b0e6e74e2f60865 /liveshare/forks-and-their-ceiling: en: 357a2147f48f5bac zh: a8a297603f984ede diff --git a/docs/subsystems/liveshare.md b/docs/subsystems/liveshare.md index 78c59825ee..abf140dd59 100644 --- a/docs/subsystems/liveshare.md +++ b/docs/subsystems/liveshare.md @@ -16,7 +16,7 @@ The plugin manager lists the card under **Experimental plugins** only while at l ## Share modes -`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. +`ViewerSessionMode` has two tiers, and both name a sandbox: `view` reads the shared Session and drives its loop inside a read-only sandbox in the granted workspace, so it never writes a file; it may use the read and search surface (`read`, `read_image`, `glob`, `grep`, `present`) and the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`), with every declared path checked against the grant. Both tiers also admit the PTC transport `run_code`: the program runs under the owner's standing file policy rather than the share tier, so it can read and write outside the grant, while its nested tool calls still pass the same guard and path checks. `write` runs in a bounded writable sandbox that also modifies files inside that workspace by adding bounded `edit` and `write`, under the Host's write ceiling. Forking is not a tier: the share carries its own `allowsFork` switch, independent of the tier. Attachment upload is not a tier either: every Session a share serves the reader accepts files, whatever the tier and the fork switch. The withdrawn `fork-readonly` name is not read back as `view`: a persisted owner record that names it is rejected when the owner state loads. Nothing infers `write`, and a share whose mode the host cannot establish stays readable but is never driven. ## Forks and their ceiling diff --git a/docs/subsystems/liveshare.zh.md b/docs/subsystems/liveshare.zh.md index e794ce64cf..cd30d9ee46 100644 --- a/docs/subsystems/liveshare.zh.md +++ b/docs/subsystems/liveshare.zh.md @@ -16,7 +16,7 @@ ## 分享模式 -`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。 +`ViewerSessionMode` 只有两档,两档都对应一个沙箱:`view` 读取共享会话,并在授予工作区的只读沙箱里驱动它的 loop,绝不写文件;它可以使用读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)和无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`),每个声明路径都按授予范围检查。`write` 在受宿主写入上限约束的可写沙箱里运行,通过有界 `edit` 与 `write` 修改该工作区内的文件。fork 不是一档:分享自带独立的 `allowsFork` 开关,与档位无关。附件上传也不是一档:分享向读者提供的每条会话都接受文件,与档位和分叉开关无关。已退场的 `fork-readonly` 名称不会读回为 `view`:持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。任何环节都不会推断出 `write`,而宿主机无法确定模式的分享仍可读取,但绝不会被驱动。两档也都准入 PTC 传输工具 `run_code`:程序运行在所有者自己的常驻文件策略下而不是分享档位下,因此可以读写授权范围之外的文件;它发起的嵌套工具调用仍走同样的守卫与路径检查。 ## fork 与其上限 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index e5b5c959fc..3397485d88 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md /web-client-slots: - en: 9df94e585e2e4809 - zh: ecc4f05a2cc54b31 + en: 15c49af3ed8b57ff + zh: e7f9189de6f4334c /web-client-slots/declaration-and-lifecycle: en: f2d2c0d3b8f14ec7 zh: 441ed13ff3f81d2a diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 87f36a0951..1e551c2e51 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -10,7 +10,7 @@ This page documents slot ownership, component inputs, extension APIs, and the sh The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime. -The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. +The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. The `sidebar.panel.icon` root-scoped factory owns the `sidebar.panellist` list and renders its registered icon by `id`, `size`, and `active` for both sidebar rows and More entries. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows. [ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 8b70a28100..8260a34075 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -10,7 +10,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。 -root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 +root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。root 作用域的 `sidebar.panel.icon` factory 拥有 `sidebar.panellist` list,按 `id`、`size` 与 `active` 为侧栏行和更多菜单入口渲染已注册图标。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。 [ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 21fa5dbbde..b2d7d88b37 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -3,26 +3,26 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md /subagents: - en: 9d48a27a49585a3d - zh: 89da32c8a16c290a + en: 3d828004dee084c6 + zh: f71296b677691151 /subagents/capabilities-and-activation-requests: - en: 8c92b131f67997f6 - zh: c2dc606a0859420c + en: f09270fcd7bfa26a + zh: 082664920b3c65e2 /subagents/local-children-and-activations: en: 6f87231d18d82916 zh: 9f064b23058f8766 /subagents/messages-and-interruption: - en: 623b06b3c12540bc - zh: f56c6341e1c62768 + en: 9befacb931520842 + zh: c214b4fffa779b06 /subagents/durable-enumeration: - en: 30c3607a969696ce - zh: e6359cdf22b1e235 + en: e02e1703b183594c + zh: 130d475a4304f453 /subagents/results-and-backend-handles: en: 69ea5bed7d9fb64a zh: b187a822b813e80a /subagents/the-provider-contract-subagentprovider: en: 226ba8094a8cf96c zh: 719678662905d1eb -/subagents/durable-enumeration-listchildren-listdescendants-and-their-entries: - en: d8ce7969b65ad86e - zh: d39dac6592681bf2 +/subagents/durable-enumeration-listdescendants-and-catalog-entries: + en: 8000138f84fd15fa + zh: 32fa91d3aebd3d01 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index b54ce57175..0e3235cf91 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -2,13 +2,13 @@ English | [中文](subagent.zh.md) -The unified activation API manages local conversations and external executions. +The unified activation API manages local conversations and external executions. Creation ancestry, runtime ownership, and communication belong to the subagent tree. [Subagent controls](../../packages/subagent/tool-subagent-control/README.md) own agent identity and exact-caller inbox waiting; Team tools add shared tasks and task policy. ## Capabilities and activation requests Every backend uses startActivation. Capability flags validate request options; prepareContinuable selects local Agent creation, while start selects one external execution. The model-facing tool uses parent delivery. Workflow and code mode consumers can await caller results without injecting another parent message. Historical children with unavailable descriptors remain discoverable as `mode: 'unknown'` without granting continuation capabilities; the [package README](../../packages/subagent/subagent/README.md) defines catalog persistence. -Optional `request.cwd` selects the child's initial directory; relative paths resolve against the parent's current effective directory. Omitting it captures that directory at admission. Local child Sessions retain the parent's original project, and cold resume retains their logged directory state. +Optional `cwd` selects the child's initial directory; relative paths resolve against the parent's current effective directory. Omitting it captures that directory at admission. Local child Sessions retain the parent's original project, and cold resume retains their logged directory state. ```ts type-equiv /** @@ -28,15 +28,13 @@ interface SubagentCapabilities { ```ts type-equiv /** One managed child execution and its result delivery policy. */ -interface SubagentActivationSpec { +interface SubagentActivationSpec extends Omit { /** Registered backend to use. */ readonly provider: string /** Short task label retained in cataloged child membership. */ readonly label: string /** Optional reserved identity for a local child; external backends allocate their own ids. */ readonly childId?: SessionId - /** Task, parent, and backend-supported execution options. */ - readonly request: Omit /** Cancellation before publication; callers own later cancellation through dispose. */ readonly signal: AbortSignal /** Parent delivery notifies the model; caller delivery only returns the result. Local children always enter the parent catalog. */ @@ -56,16 +54,15 @@ interface SubagentActivation { * Teardown failures are reported by dispose() without replacing a captured result. */ readonly result: Promise - /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ + /** Stop and release only this activation; rejects on teardown failure. */ dispose(): Promise } ``` ```ts type-equiv /** - * Task and optional capabilities supplied to a backend. startActivation carries - * these fields in request while owning its label, cancellation, and delivery - * policy separately. + * Task and optional capabilities supplied to a backend. The activation spec + * shares these task options and defines its own label and cancellation policy. */ interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ @@ -173,7 +170,7 @@ interface ContinuableCreateSpec { ## Messages and interruption -sendMessage authorizes the exact live sender and permits only adjacent local agents. Running children receive steering input; absent continuable children resume from persistence. External executions reject follow-ups. Both local and external executions automatically report their final text, structured result, diagnostic, and status in the parent settlement notice. Local children can also send messages through send_message. caller delivery suppresses settlement notices. drainDescendants and drainChildren await owned work teardown for local and external activations. +`sendMessage` requires the exact live sender and a target Session id in the same root tree. Root and continuable members can message across branches; admission revalidates the sender and root. Running targets receive Steer; only an absent target cold-resumes, while its creation ancestors may remain offline. External executions reject follow-ups. Local and external executions report final text, structured output, diagnostics, and status to their direct creator only while it is online; caller delivery suppresses those notices. `drainDescendants` awaits whole-tree teardown; an activation receipt’s `dispose()` releases only that execution. ```ts type-equiv /** Durable attribution for one model-authored message between Agents. */ @@ -200,13 +197,15 @@ interface SubagentSettledMessageSource { readonly form: 'notice' /** One-line account of how the child ended. */ readonly summary: string + /** Exact execution stop reason; absent on historical notices. Writers always include it. */ + readonly stopReason?: SubagentResult['stopReason'] /** Session id of the child that settled. */ readonly senderSessionId: SessionId } ``` ```ts type-equiv -/** Options for one model-authored message between adjacent Agents. */ +/** Options for one model-authored message within a subagent tree. */ interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal @@ -215,18 +214,18 @@ interface SubagentSendMessageOptions { ```ts type-equiv /** - * Authority under which one interrupt request is admitted. `user` carries the - * durable direct-parent address a human client presented; `ancestor` carries - * the exact live Agent object whose recorded lineage must contain the caller. + * Authority under which one interrupt request is admitted. `user` is a trusted + * host operation; Remote callers need permission on the target Session before + * invoking it. `ancestor` carries an exact live Agent in the target's creation ancestry. */ type SubagentInterruptAuthority = - | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'user' } | { readonly kind: 'ancestor'; readonly agent: Agent } ``` ## Durable enumeration -`listChildren` reads the parent-owned catalog of all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes direct continuable children with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents). +The parent-owned `subagentCatalog` projection lists all local children and parent-delivery external children. Caller-delivery external executions leave membership and result collection to their caller; local workflow children remain discoverable after completion. External entries carry `mode: 'external'` and cannot open a child Session or accept follow-ups. Catalog entries record membership at creation without execution status. `list_agents` exposes the root and reachable continuable descendants with `running` or `inactive` activity; see the [control tool](../../packages/subagent/tool-subagent-control/README.md#list_agents). ## Results and backend handles @@ -372,11 +371,11 @@ interface SubagentProvider { } ``` -## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries +## Durable enumeration: `listDescendants()` and catalog entries The model-facing `list_agents` adapter reports current activity as `running` or `inactive`. These values do not describe task completion or guarantee that `send_message` will succeed. -`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. +Direct-child entries are available as `observation.projections?.values.subagentCatalog` through `ctx.sessionQuery.observeSession(parentSessionId, { signal })`; callers release the observation with `using`. Entries follow parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection means discovery is unavailable. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` recursively reads those catalogs and derives `hasChildren` from each child catalog. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs. `SubagentRuntime.listDescendants(rootSessionId)` recursively calls the same catalog reader in stable pre-order, preserving each parent's event order. External entries are leaves without local Sessions. One-shot and unknown-mode entries remain traversal nodes; unknown modes produce `unsupported` diagnostics. An unreadable child catalog produces `corrupt` or `unavailable` and stops only that branch. Root read failures, missing services or projections, and cancellation reject the listing. Each reachable catalog is observed once and released before the next read; repeated ids and cycles are skipped. Sessions absent from reachable catalogs are not discovered, including ordinary Session forks and any subagents below those forks. Each row carries its catalog parent and root-relative depth: @@ -437,32 +436,39 @@ startActivation(spec: SubagentActivationSpec & { readonly childId: SessionId }): startActivation(spec: SubagentActivationSpec): Promise /** - * Join progressing descendants without cancelling them. Idle descendants whose - * inboxes require a later wake stay resident and do not delay host completion. - * @param parent - the exact parent whose descendant work is observed. - * @returns whether work was joined; hosts recheck parent idle after true. + * Resolve the root shared by an Agent and its other agents in its subagent tree. + * @param caller - exact live Agent whose subagent ancestry selects the tree. + * @returns the live root; ordinary Session forks lead independent trees. + * @throws when the caller or its runtime owner is unavailable. */ -async waitForChildren(parent: Agent): Promise +rootOf(caller: Agent): Agent /** - * Steer one model-authored message to the sender's direct parent or direct - * continuable child. A running target admits it at the nearest step boundary; - * an idle target starts a turn, and an absent direct child cold-resumes from - * persistence. The service derives durable sender attribution from the exact - * live sender. Caller cancellation stops only pre-acceptance work. - * @param sender - exact live Agent authorizing and originating the message. - * @param targetId - durable direct-parent or direct-child session id. + * Join progressing subagent tree work for its root. Idle members with parked input + * stay resident without delaying host completion. Ordinary members return false. + * @param root - exact live subagent tree root whose member work is awaited. + * @returns whether work was joined; hosts recheck root idle after true. + */ +async waitForTreeWork(root: Agent): Promise + +/** + * Steer one model-authored message to another member of the same subagent tree. + * A running target receives it at its next step; an idle target starts a turn. + * A cold target resumes under its runtime subagent tree root without restoring + * its creation ancestors. Sender identity is rechecked before inbox acceptance; cancellation + * stops only pre-acceptance work and never retracts an accepted message. + * @param sender - exact live root or resident continuable Agent originating the message. + * @param targetId - another root or continuable member's durable Session id. * @param content - model-authored content to deliver. * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, adjacency is rejected, - * or the message was not admitted. + * @throws when the target is outside the tree, cannot resume, or rejects input. */ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** - * Interrupt one live child's current execution under a human parent - * address or an exact live ancestor Agent. Fire-and-return: the cancel + * Interrupt one live child's current execution under a trusted user + * operation or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. @@ -471,47 +477,22 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. - * @param authority - the human parent address or exact live ancestor Agent. + * @param authority - a trusted user operation or exact live ancestor Agent. * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the * live target. */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void /** - * Close subagent admission below exact live parent Agents, stop only their - * visible descendant Activations synchronously, then await admitted scoped - * materializations and release those forests child-first. The scoped cutoff - * lasts until each exact parent leaves the registry; unrelated parent trees - * remain live. - * @param parents - exact host-owned parent Agents entering teardown. - * @returns once every retained descendant activation released its execution handle. + * Close admission and stop all instances in the selected subagent trees, then await + * pending startup and cleanup. Ordinary members are ignored. The cutoff lasts + * until each exact root leaves the registry; unrelated subagent trees remain live. + * @param parents - exact live subagent tree roots entering teardown. + * @returns once all admitted startup and member cleanup have settled. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise -/** - * Release selected resident direct children of one exact live - * parent. Other children of the same parent remain admitted and resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ -async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise - -/** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -531,8 +512,8 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** - * Deliver one browser-authored message to a continuable child through the - * exact live direct parent, retaining the caller-minted request identity and + * Deliver one browser-authored message to a continuable child through its + * live subagent tree root, retaining the caller-minted request identity and * validated browser zone on the accepted message. Success identifies the * message the child's inbox accepted; later execution is independent of this * call. Queue delivery targets a later turn; steer delivery targets the @@ -540,31 +521,27 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise +@Remote({ name: 'prompt', resources: { session: request => request.childSessionId } }) async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise /** - * Remote face of {@link interrupt} under one durable parent address. No - * catalog, history, persistence, or parent Agent lookup runs: the core - * primitive alone authorizes the address against the live Activation, which - * is what keeps a live child interruptible while its parent Agent is offline. + * Remote face of {@link interrupt}, authorized on the target Session by the Gateway. + * No catalog, history, persistence, or parent Agent lookup runs. * Absent, idle, and already-completed targets are accepted no-ops there. * @param childSessionId - durable child session id to interrupt. - * @param parentSessionId - durable direct parent whose authority is claimed. - * @param mode - required continuable-address discriminator. * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. * @throws {RemoteError} `gateway/bad-request` for an empty id, - * `subagent/unauthorized` when the address does not own the live target, * otherwise `gateway/internal`. */ -@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt +@Remote({ name: 'interruptByUser', resources: { session: childSessionId => childSessionId } }) interruptByUser(childSessionId: SessionId): SubagentInterruptReceipt /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -582,12 +559,6 @@ registerProvider(provider: SubagentProvider): () => void * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined - -/** - * List registered provider names in insertion order. - * @returns the registered names. - */ -list(): string[] ``` Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) @@ -602,14 +573,14 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/end` — emit -A published child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. +A published child settled. Scope-filtered dispatch uses the same subagent tree-root carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog /** - * A published child settled. Scope-filtered dispatch uses the same delegating - * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * A published child settled. Scope-filtered dispatch uses the same subagent tree-root + * carrier as `subagent/start`, so the lifecycle pair reaches the * same scoped audience. - * @param info - the run identity and terminal outcome. + * @param info - run identity, durable direct creator `parentSessionId`, and terminal outcome. * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void @@ -657,16 +628,16 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/start` — emit -A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. +A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the subagent tree root, so a root-scoped listener observes all subagent tree activations. Paired with `subagent/end`. ```ts cordis-catalog /** * A provider established a published child. For in-process providers, * `ctx.agents.get(info.id)` resolves during this notification. - * Scope-filtered dispatch keys the carrier by the delegating parent, so a - * parent-scoped listener observes only its own delegations. Paired with + * Scope-filtered dispatch keys the carrier by the subagent tree root, so a + * root-scoped listener observes all subagent tree activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0ca87c8503..785644d1cf 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -2,13 +2,13 @@ [English](subagent.md) | 中文 -统一的 activation API 管理本地会话和外部执行。 +统一的 activation API 管理本地对话和外部执行。创建祖先关系、运行归属和通信由 subagent 树管理。[subagent 控制工具](../../packages/subagent/tool-subagent-control/README.zh.md)负责 agent 身份和当前调用者的收件箱等待;Team 工具添加共享任务与任务策略。 ## 能力与 activation 请求 所有后端都使用 startActivation。能力标记校验请求选项;prepareContinuable 选择本地 Agent 创建,start 选择一次外部执行。面向模型的工具采用 parent 回传。Workflow 与 code mode 消费者可以等待 caller 结果,不额外向父代理注入消息。历史子会话的 descriptor 不可用时,以 `mode: 'unknown'` 保持可发现性,但不授予继续执行能力;[包 README](../../packages/subagent/subagent/README.zh.md) 定义目录持久化语义。 -可选的 `request.cwd` 指定子级的初始目录;相对路径以父级当前有效目录为基准解析。省略时,在准入阶段捕获该目录。本地子 Session 保留父级的原始项目,冷恢复保留其日志中的目录状态。 +可选的 `cwd` 指定子级的初始目录;相对路径以父级当前有效目录为基准解析。省略时,在准入阶段捕获该目录。本地子 Session 保留父级的原始项目,冷恢复保留其日志中的目录状态。 ```ts type-equiv /** @@ -28,15 +28,13 @@ interface SubagentCapabilities { ```ts type-equiv /** One managed child execution and its result delivery policy. */ -interface SubagentActivationSpec { +interface SubagentActivationSpec extends Omit { /** Registered backend to use. */ readonly provider: string /** Short task label retained in cataloged child membership. */ readonly label: string /** Optional reserved identity for a local child; external backends allocate their own ids. */ readonly childId?: SessionId - /** Task, parent, and backend-supported execution options. */ - readonly request: Omit /** Cancellation before publication; callers own later cancellation through dispose. */ readonly signal: AbortSignal /** Parent delivery notifies the model; caller delivery only returns the result. Local children always enter the parent catalog. */ @@ -56,16 +54,15 @@ interface SubagentActivation { * Teardown failures are reported by dispose() without replacing a captured result. */ readonly result: Promise - /** Stop and release this activation and its owned descendants; rejects on teardown failure. */ + /** Stop and release only this activation; rejects on teardown failure. */ dispose(): Promise } ``` ```ts type-equiv /** - * Task and optional capabilities supplied to a backend. startActivation carries - * these fields in request while owning its label, cancellation, and delivery - * policy separately. + * Task and optional capabilities supplied to a backend. The activation spec + * shares these task options and defines its own label and cancellation policy. */ interface SubagentStartRequest { /** Initial child directory; relative paths resolve against the parent's current directory. Omitted inherits that directory at start. */ @@ -173,7 +170,7 @@ interface ContinuableCreateSpec { ## 消息与中断 -sendMessage 根据确切的在线发送者实例授权,仅允许相邻本地代理通信。运行中的子代理接收引导输入;不在线的可继续子代理从持久化恢复。外部执行拒绝后续消息。本地与外部执行都会通过父级结束通知自动回传最终文本、结构化结果、诊断和状态。本地子代理也可以通过 send_message 发送消息。caller 回传会抑制结束通知。drainDescendants 与 drainChildren 对本地和外部 activation 都等待所属任务清理完成。 +`sendMessage` 要求确切在线发送方和同一根树中的目标 Session id。根和可继续成员可跨分支发消息;接纳时重新校验发送方和根。运行中目标接收 Steer;只冷恢复不在线的目标,其创建祖先可以保持离线。外部执行拒绝后续输入。本地和外部执行仅向仍在线的直接创建者报告最终文本、结构化输出、诊断和状态;caller 投递抑制这些通知。`drainDescendants` 等待整棵树释放;activation 回执的 `dispose()` 仅释放那一次执行。 ```ts type-equiv /** Durable attribution for one model-authored message between Agents. */ @@ -200,13 +197,15 @@ interface SubagentSettledMessageSource { readonly form: 'notice' /** One-line account of how the child ended. */ readonly summary: string + /** Exact execution stop reason; absent on historical notices. Writers always include it. */ + readonly stopReason?: SubagentResult['stopReason'] /** Session id of the child that settled. */ readonly senderSessionId: SessionId } ``` ```ts type-equiv -/** Options for one model-authored message between adjacent Agents. */ +/** Options for one model-authored message within a subagent tree. */ interface SubagentSendMessageOptions { /** Caller cancellation, owning the operation only until inbox acceptance. */ readonly signal: AbortSignal @@ -215,18 +214,18 @@ interface SubagentSendMessageOptions { ```ts type-equiv /** - * Authority under which one interrupt request is admitted. `user` carries the - * durable direct-parent address a human client presented; `ancestor` carries - * the exact live Agent object whose recorded lineage must contain the caller. + * Authority under which one interrupt request is admitted. `user` is a trusted + * host operation; Remote callers need permission on the target Session before + * invoking it. `ancestor` carries an exact live Agent in the target's creation ancestry. */ type SubagentInterruptAuthority = - | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'user' } | { readonly kind: 'ancestor'; readonly agent: Agent } ``` ## 持久枚举 -`listChildren` 读取父级拥有的目录,其中包含所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示可继续的直接子级,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 +父级拥有的 `subagentCatalog` 投影列出所有本地子级及 parent 投递的外部子级。caller 投递的外部执行由调用方负责成员关系与结果收集;本地工作流子级在完成后仍可被发现。外部条目携带 `mode: 'external'`,不能打开子 Session 或接受后续消息。目录在创建时记录成员关系,不携带执行状态。`list_agents` 展示根和可达的可继续后代,其活跃状态为 `running` 或 `inactive`;参见[控制工具](../../packages/subagent/tool-subagent-control/README.zh.md#list_agents)。 ## 结果与后端句柄 @@ -372,11 +371,11 @@ interface SubagentProvider { } ``` -## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目 +## 持久化枚举:`listDescendants()` 与目录条目 模型侧的 `list_agents` 适配器将当前活动表示为 `running` 或 `inactive`。这些值不描述任务完成情况,也不保证 `send_message` 会成功。 -`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 +通过 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,可从 `observation.projections?.values.subagentCatalog` 获取直接子级条目;调用方使用 `using` 释放观察对象。条目遵循父会话事件顺序,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影意味着发现功能不可用。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 递归读取这些目录,并从每个子级目录推导 `hasChildren`。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。 `SubagentRuntime.listDescendants(rootSessionId)` 以稳定前序递归调用同一目录读取函数,保留每个父级的事件顺序。外部条目是没有本地 Session 的叶节点。一次性和未知模式条目仍是遍历节点;未知模式产生 `unsupported` 诊断。无法读取的子级目录产生 `corrupt` 或 `unavailable`,只停止该分支。根读取失败、服务或投影缺失、取消会使整个查询失败。每个可达目录只观察一次,并在下一次读取前释放;重复 id 和循环引用会被跳过。可达目录中不存在的 Session 不会被发现,包括普通 Session fork 及其下的子 agent。每行携带目录中的父级和相对根的深度: @@ -439,32 +438,39 @@ startActivation(spec: SubagentActivationSpec & { readonly childId: SessionId }): startActivation(spec: SubagentActivationSpec): Promise /** - * Join progressing descendants without cancelling them. Idle descendants whose - * inboxes require a later wake stay resident and do not delay host completion. - * @param parent - the exact parent whose descendant work is observed. - * @returns whether work was joined; hosts recheck parent idle after true. + * Resolve the root shared by an Agent and its other agents in its subagent tree. + * @param caller - exact live Agent whose subagent ancestry selects the tree. + * @returns the live root; ordinary Session forks lead independent trees. + * @throws when the caller or its runtime owner is unavailable. */ -async waitForChildren(parent: Agent): Promise +rootOf(caller: Agent): Agent /** - * Steer one model-authored message to the sender's direct parent or direct - * continuable child. A running target admits it at the nearest step boundary; - * an idle target starts a turn, and an absent direct child cold-resumes from - * persistence. The service derives durable sender attribution from the exact - * live sender. Caller cancellation stops only pre-acceptance work. - * @param sender - exact live Agent authorizing and originating the message. - * @param targetId - durable direct-parent or direct-child session id. + * Join progressing subagent tree work for its root. Idle members with parked input + * stay resident without delaying host completion. Ordinary members return false. + * @param root - exact live subagent tree root whose member work is awaited. + * @returns whether work was joined; hosts recheck root idle after true. + */ +async waitForTreeWork(root: Agent): Promise + +/** + * Steer one model-authored message to another member of the same subagent tree. + * A running target receives it at its next step; an idle target starts a turn. + * A cold target resumes under its runtime subagent tree root without restoring + * its creation ancestors. Sender identity is rechecked before inbox acceptance; cancellation + * stops only pre-acceptance work and never retracts an accepted message. + * @param sender - exact live root or resident continuable Agent originating the message. + * @param targetId - another root or continuable member's durable Session id. * @param content - model-authored content to deliver. * @param options - caller cancellation before inbox acceptance. * @returns the accepted message's inbox id. - * @throws when continuation services are unavailable, adjacency is rejected, - * or the message was not admitted. + * @throws when the target is outside the tree, cannot resume, or rejects input. */ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], options: SubagentSendMessageOptions, ): Promise /** - * Interrupt one live child's current execution under a human parent - * address or an exact live ancestor Agent. Fire-and-return: the cancel + * Interrupt one live child's current execution under a trusted user + * operation or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running * until it observes the signal. Unclaimed pending inbox work, the Activation, * and published descendants are preserved; claimed work is not requeued. @@ -473,47 +479,22 @@ async sendMessage( sender: Agent, targetId: SessionId, content: ContentBlock[], * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. - * @param authority - the human parent address or exact live ancestor Agent. + * @param authority - a trusted user operation or exact live ancestor Agent. * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the * live target. */ interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void /** - * Close subagent admission below exact live parent Agents, stop only their - * visible descendant Activations synchronously, then await admitted scoped - * materializations and release those forests child-first. The scoped cutoff - * lasts until each exact parent leaves the registry; unrelated parent trees - * remain live. - * @param parents - exact host-owned parent Agents entering teardown. - * @returns once every retained descendant activation released its execution handle. + * Close admission and stop all instances in the selected subagent trees, then await + * pending startup and cleanup. Ordinary members are ignored. The cutoff lasts + * until each exact root leaves the registry; unrelated subagent trees remain live. + * @param parents - exact live subagent tree roots entering teardown. + * @returns once all admitted startup and member cleanup have settled. * @throws an aggregate error after all branches settle when any failed. */ async drainDescendants(parents: readonly Agent[]): Promise -/** - * Release selected resident direct children of one exact live - * parent. Other children of the same parent remain admitted and resident. - * Absent targets and a manager-less composition are accepted no-ops. - * @param parent - exact live direct parent authorizing the selected release. - * @param childIds - durable direct-child ids to release when resident. - * @returns once every selected activation released its execution handle. - * @throws {SubagentError} `UNAUTHORIZED` when a resident target belongs to a - * different parent or the supplied parent identity is stale. - */ -async drainChildren(parent: Agent, childIds: readonly SessionId[]): Promise - -/** - * Read the parent's durable direct-child catalog without loading or resuming an Agent. - * The service owns and releases the live-preferred Session observation. - * @param parentSessionId - parent whose direct children are requested. - * @param signal - cancellation forwarded to the Session query. - * @returns catalog children in parent event order. - * @throws {@link SubagentError} when query or catalog projection is unavailable. - * @throws SessionQueryError when the parent cannot be read or the query is cancelled. - */ -listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise - /** * Recursively list reachable parent catalogs in stable pre-order, preserving * each catalog's event order. Each row carries its catalog parent and depth; @@ -533,8 +514,8 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise /** - * Deliver one browser-authored message to a continuable child through the - * exact live direct parent, retaining the caller-minted request identity and + * Deliver one browser-authored message to a continuable child through its + * live subagent tree root, retaining the caller-minted request identity and * validated browser zone on the accepted message. Success identifies the * message the child's inbox accepted; later execution is independent of this * call. Queue delivery targets a later turn; steer delivery targets the @@ -542,31 +523,27 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise +@Remote({ name: 'prompt', resources: { session: request => request.childSessionId } }) async prompt(request: SubagentPromptRequest, signal: AbortSignal): Promise /** - * Remote face of {@link interrupt} under one durable parent address. No - * catalog, history, persistence, or parent Agent lookup runs: the core - * primitive alone authorizes the address against the live Activation, which - * is what keeps a live child interruptible while its parent Agent is offline. + * Remote face of {@link interrupt}, authorized on the target Session by the Gateway. + * No catalog, history, persistence, or parent Agent lookup runs. * Absent, idle, and already-completed targets are accepted no-ops there. * @param childSessionId - durable child session id to interrupt. - * @param parentSessionId - durable direct parent whose authority is claimed. - * @param mode - required continuable-address discriminator. * @returns acknowledgement that the cancel signal was admitted, not that the target is quiescent. * @throws {RemoteError} `gateway/bad-request` for an empty id, - * `subagent/unauthorized` when the address does not own the live target, * otherwise `gateway/internal`. */ -@Remote('interruptByParent') interruptByParent( childSessionId: SessionId, parentSessionId: SessionId, mode: 'continuable', ): SubagentInterruptReceipt +@Remote({ name: 'interruptByUser', resources: { session: childSessionId => childSessionId } }) interruptByUser(childSessionId: SessionId): SubagentInterruptReceipt /** * Register a provider under its name. Registration is effect-scoped and HMR @@ -584,12 +561,6 @@ registerProvider(provider: SubagentProvider): () => void * @returns the provider, or undefined when absent. */ getProvider(name: string): SubagentProvider | undefined - -/** - * List registered provider names in insertion order. - * @returns the registered names. - */ -list(): string[] ``` Types: [Agent](core.zh.md) · [ContentBlock](llm-streaming.zh.md) · [MessageId](llm-streaming.zh.md) · [SessionId](core.zh.md) @@ -604,14 +575,14 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/end` — emit -A published child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. +A published child settled. Scope-filtered dispatch uses the same subagent tree-root carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog /** - * A published child settled. Scope-filtered dispatch uses the same delegating - * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * A published child settled. Scope-filtered dispatch uses the same subagent tree-root + * carrier as `subagent/start`, so the lifecycle pair reaches the * same scoped audience. - * @param info - the run identity and terminal outcome. + * @param info - run identity, durable direct creator `parentSessionId`, and terminal outcome. * @mode emit */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void @@ -659,16 +630,16 @@ Source: [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/suba #### `subagent/start` — emit -A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. +A provider established a published child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the subagent tree root, so a root-scoped listener observes all subagent tree activations. Paired with `subagent/end`. ```ts cordis-catalog /** * A provider established a published child. For in-process providers, * `ctx.agents.get(info.id)` resolves during this notification. - * Scope-filtered dispatch keys the carrier by the delegating parent, so a - * parent-scoped listener observes only its own delegations. Paired with + * Scope-filtered dispatch keys the carrier by the subagent tree root, so a + * root-scoped listener observes all subagent tree activations. Paired with * `subagent/end`. - * @param info - the provider and published child identity. + * @param info - provider, child identity, and durable direct creator `parentSessionId`. * @mode emit */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index 2a2463b475..2c86b22e1c 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -278,17 +278,16 @@ async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: Tool * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the - * exact live runtime root. Runtime ownership, not durable session lineage, - * decides this boundary: an owned child has no human answerer and would - * block forever, while a lineage-bearing session resumed as a new runtime - * root may ask normally. + * exact live instance of an ordinary Session. Explicit subagent origin + * rejects delegated callers; historical ordinary forks may retain parent + * metadata and still ask normally. * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent * is not the registry's exact live instance, or `DELEGATED_CALLER` when - * that live agent is owned by another agent. + * that live agent has subagent origin. */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index f433339045..ad39e1004f 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -278,17 +278,16 @@ async askTimed( request: AskUserQuestionRequest & { agent: Agent }, callId: Tool * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the - * exact live runtime root. Runtime ownership, not durable session lineage, - * decides this boundary: an owned child has no human answerer and would - * block forever, while a lineage-bearing session resumed as a new runtime - * root may ask normally. + * exact live instance of an ordinary Session. Explicit subagent origin + * rejects delegated callers; historical ordinary forks may retain parent + * metadata and still ask normally. * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent * is not the registry's exact live instance, or `DELEGATED_CALLER` when - * that live agent is owned by another agent. + * that live agent has subagent origin. */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 593dcc3494..f201ba2f7e 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -9,8 +9,8 @@ en: c0454bca818f86ee zh: 427582150fd3b976 /web-client-architecture/browser-boot: - en: 5ac7d3aed7bf273a - zh: e34869c8ad64052d + en: cd68c46a3f365235 + zh: 9ac2f7c65cabb144 /web-client-architecture/remote-communication: en: 426b09164210b77a zh: ce576099ee668984 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index bb68cd64a1..93f2fbdf1d 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -23,7 +23,7 @@ The dependency direction is Host state → Remote transport → Client model → The Host writes the composed `WebBootGraph` to `window.__DSH_BOOT__` and installs the browser module-loader facade before parser-preloaded scripts execute. The module system is a lazy CommonJS table: loading a bundle registers its factory, while materializing an entry runs the factory with synchronous `require` over platform modules and declared dynamic dependencies. -The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state, `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. +The Web boot kernel creates the module system, prefetches `immediately` entries, mounts the vendored Cordis Loader, and creates every graph entry. Cordis service injection determines activation; module graph order determines only whether synchronous imports can be materialized. After the complete roster reaches a settled state and the boot audit accepts it (an inactive entry on the browser required list rejects startup; other inactive entries produce one console warning), `ui-renderer` hydrates the framework-free boot DOM and calls the sole context-level `renderSlot('root')` operation. [Client Modules](client-modules.md) owns the graph, bundle route, cache revision, and loader details. ## Remote communication diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index a3d62f738f..3c0c0da5bb 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -23,7 +23,7 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。 Host 把组合后的 `WebBootGraph` 写入 `window.__DSH_BOOT__`,并在 parser-preloaded script 执行前安装浏览器 module-loader facade。模块系统是一张 lazy CommonJS 表:加载 bundle 只注册 factory;materialize entry 时才以同步 `require` 运行 factory,并解析 platform module 和已声明的动态依赖。 -Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态后,`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 +Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendored Cordis Loader,再创建图中的每个 entry。Cordis service injection 决定激活顺序;module graph 顺序只决定同步 import 能否被 materialize。完整 roster 到达 settled 状态且启动审计通过后(浏览器必需名单中的 entry 未激活会拒绝启动;其他未激活 entry 输出一条控制台警告),`ui-renderer` hydrate 不依赖框架的 boot DOM,并调用唯一一次 context 级 `renderSlot('root')`。[Client Modules](client-modules.zh.md)负责 graph、bundle route、cache revision 与 loader 细节。 ## Remote 通信 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 22deacea2a..1e5054c723 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: f790063d536da1bf - zh: 6933b54fc70cd470 + en: 17d0b1d1a3c15c7f + zh: 1a9d90fc99e0e848 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -246,23 +246,26 @@ en: 841abe0dd5913ea7 zh: 841abe0dd5913ea7 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/list-subagent-models: - en: 207228068bc61885 - zh: 36e9344ee1af8869 + en: 0e21650bbe8c71c7 + zh: fe4033efb38d51a3 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent: - en: 140c965c2b04d02f - zh: 95495dad5c77046d + en: 430672da3c1a6ca6 + zh: bd3a9ab4089f9dbb /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control: en: ab82e130c3413f04 zh: ab82e130c3413f04 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent: - en: 9190a32df5e10ddd + en: a622b15cc2598d51 zh: 48e1b05956d63e26 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents: - en: ae22e8543d123c39 - zh: 0991a73ef49bcdea + en: a56168133c21c6a0 + zh: f9df1c0fe49fa225 /tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message: - en: 9d180d8811903366 - zh: d63f94cffcb952d1 + en: b53326fedeab850d + zh: ecb75b6ce31da009 +/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/wait-agent: + en: f3cf5a0c56ddc07a + zh: 7f6754d71e1b4ae4 /tool-schema-catalog/deepseek-ai-dsh-tool-jobs: en: 9be673b1604d0114 zh: 9be673b1604d0114 @@ -273,38 +276,23 @@ en: 0c37fb1fed979d72 zh: 5ea9d07f5a4a8a6c /tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-output: - en: 1e084359a8cd42b3 - zh: 3f6c245dbbb2b920 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team: - en: 6b1106f417b50db6 - zh: 6b1106f417b50db6 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/interrupt-agent: - en: c3c7af05ceb9f8c9 - zh: ec379e0b42110c6a -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/list-agents: - en: f9457f033f75f00a - zh: a2d60ec44118c34c -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/send-message: - en: ec50e6a0b86aecc6 - zh: 29c8728536a5adf1 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/spawn-teammate: - en: 02d6cc746e234b99 - zh: 49bc60ac098bca48 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-create: - en: 91e84febe063c3bd - zh: 550555d0f44f603b -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-get: - en: 1c6ae0aba0b8041d - zh: f8d634387b74121d -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-list: - en: 2084d494aae8798a - zh: e4c136c91028a7b1 -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/team-task-update: - en: 2eac7360ba68b1bd - zh: b5d676bf288c245e -/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/wait-agent: - en: e70c77a45832bb28 - zh: 7d03bec294b65452 + en: 2977f731fb90d147 + zh: 4f422613ff720055 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team: + en: 3f1a9c3509853972 + zh: 3f1a9c3509853972 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-create: + en: ac9ed819b705b4b8 + zh: 306477d2d457369a +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-get: + en: bca1abf330638c19 + zh: 903bfa977f75c046 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-list: + en: 8001eda7e9ff6a25 + zh: 2414761029f21404 +/tool-schema-catalog/deepseek-ai-dsh-tool-agent-team/team-task-update: + en: 9acd1c5b2fac9853 + zh: 3bd81e9cd1f1c28d /tool-schema-catalog/deepseek-ai-dsh-tool-todo: en: dea62d26fb18ff68 zh: dea62d26fb18ff68 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 9da0029e4f..850ce0670f 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -41,9 +41,9 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `ctx.agents`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | The subagent_oar tool selects among configured aliases for external providers. This catalog shows Codex and Claude Code aliases; each deployment chooses its providers. Native model and permission settings belong to the selected provider configuration. | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.sessionProjections`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while one instance of this package registers `send_message`, `interrupt_agent`, and `list_agents`. | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message`, `wait_agent` | `ctx.tools`, `ctx.subagents`, `ctx.agents` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | -| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/task`, `agent/inbox/spliced`, `user/message on inbox claim`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | +| `@deepseek-ai/dsh-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | Four task-board tools share the subagent tree’s root Session. Enabling Team mode on the root Session adds task-board guidance alongside standard subagent delegation and control tools. Standard subagent controls provide required identity independently of Team mode. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | Reads or changes the calling Session directory. Existing shells and processes retain their own directories; origin metadata and permission roots stay fixed. | | `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | Enabled through the Change requests & worktrees bundle on Web and Desktop; other profiles can select the standalone bundle. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. | @@ -2039,7 +2039,7 @@ The subagent_oar tool selects among configured aliases for external providers. T ### `list_subagent_models` -Inspect subagent models without changing the current Agent. No arguments: providers; `provider`: allowed models; `provider` + `model`: that model and its reasoning efforts. Inspect options here before selecting a child provider and model; use returned ids for delegation. +Inspect the routes available for explicit subagent model selection. No arguments: authorized providers. `provider`: that provider's authorized models. `provider` + `model`: that model and its reasoning efforts. Query when choosing an explicit child route or reasoning effort. Omit optional fields rather than sending empty strings. ```json { @@ -2061,7 +2061,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. The runtime notifies you when it finishes. The child reports results with `send_message`; use `send_message` to steer it while running or continue its conversation after it finishes. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes. ```json { @@ -2073,7 +2073,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its }, "description": { "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." + "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." }, "prompt": { "type": "string", @@ -2097,7 +2097,7 @@ The load-time `toolName` selects the delegation name (default `subagent`); `mode ### `interrupt_agent` -Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local direct child's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. +Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a local subagent's conversation later with send_message. External executions stop permanently and cannot receive follow-ups. Subagents it started will keep running. ```json { @@ -2118,21 +2118,12 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub ### `list_agents` -List subagents you started, with their ids, labels, and status. running means it is working; inactive means it is not currently working. You will be notified when a subagent finishes; there is no need to keep checking its status. Use send_message to continue the conversation. +List your entire subagent tree: the root agent and all reachable continuable descendants, including yourself. Rows carry ids, descriptions, and status; descendant rows carry parent ids and depth from the root. You can message any other agent in the subagent tree listed as running or inactive. running means it is working; inactive means no turn is executing; it does not indicate task completion, success, or failure. While you remain active, you will be notified when a direct child finishes; there is no need to keep checking its status. Use send_message to continue the conversation. ```json { "type": "object", - "properties": { - "scope": { - "type": "string", - "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.", - "enum": [ - "children", - "descendants" - ] - } - } + "properties": {} } ``` @@ -2140,7 +2131,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag ### `send_message` -Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent's answer. +Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation. ```json { @@ -2148,7 +2139,7 @@ Send a message to an agent. A working agent receives it at its next step; an idl "properties": { "agent_id": { "type": "string", - "description": "The agent id of your direct continuable child, or your direct parent when you are a resident continuable child." + "description": "The agent id of another member of your subagent tree, as shown by list_agents." }, "message": { "type": "string", @@ -2164,7 +2155,25 @@ Send a message to an agent. A working agent receives it at its next step; an idl Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while one instance of this package registers `send_message`, `interrupt_agent`, and `list_agents`. +### `wait_agent` + +Wait for input at your next step, including agent messages, completion notifications, and user steering. Returns immediately when that input is already pending. + +```json +{ + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } +} +``` + +Source: [`packages/subagent/tool-subagent-control/src/wait-agent.ts`](../packages/subagent/tool-subagent-control/src/wait-agent.ts) + +The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. @@ -2239,108 +2248,9 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/ The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. - + -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -### `interrupt_agent` - -Interrupt one teammate's current turn while preserving its pending inbox. Team Lead only. - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Teammate target returned by spawn_teammate or list_agents." - } - }, - "required": [ - "target" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `list_agents` - -List the Lead and every durable teammate with an addressable target and current availability. inactive means no turn is executing, not a task result. provisioning and failed describe member creation. - -```json -{ - "type": "object", - "properties": {} -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `send_message` - -Send one message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn. - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Member target returned by spawn_teammate or list_agents, including lead." - }, - "message": { - "type": "string", - "description": "Self-contained message for the target." - } - }, - "required": [ - "target", - "message" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `spawn_teammate` - -Create one named, durable teammate. Only the Team Lead may call this tool. - -```json -{ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique lower-kebab-case teammate name." - }, - "description": { - "type": "string", - "description": "Short description of the delegated responsibility." - }, - "prompt": { - "type": "string", - "description": "Complete initial task for the teammate." - }, - "context": { - "type": "string", - "description": "fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.", - "enum": [ - "fresh", - "fork" - ] - } - }, - "required": [ - "name", - "description", - "prompt" - ] -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +## `@deepseek-ai/dsh-tool-agent-team` ### `team_task_create` @@ -2380,7 +2290,7 @@ Create one unowned pending task on the shared Team task board. } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_get` @@ -2401,7 +2311,7 @@ Read the complete latest value of one shared task before changing or executing i } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_list` @@ -2422,7 +2332,7 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco }, "owner": { "type": "string", - "description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner." + "description": "Optional exact ownerId filter; use unowned for tasks without an owner." }, "ready": { "type": "boolean", @@ -2440,7 +2350,7 @@ List shared tasks, including readiness, owner, revision, blockers, and write-sco } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_update` @@ -2496,7 +2406,7 @@ Compare-and-set a shared task action using the latest revision from team_task_ge }, "owner": { "type": "string", - "description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign." + "description": "Agent id of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ @@ -2507,27 +2417,9 @@ Compare-and-set a shared task action using the latest revision from team_task_ge } ``` -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +Source: [`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) -### `wait_agent` - -Wait for the next teammate status, message, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling. - -```json -{ - "type": "object", - "properties": { - "timeout_ms": { - "type": "integer", - "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." - } - } -} -``` - -Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. +Four task-board tools share the subagent tree’s root Session. Enabling Team mode on the root Session adds task-board guidance alongside standard subagent delegation and control tools. Standard subagent controls provide required identity independently of Team mode. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 19e6f8beb5..907ef84924 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -45,9 +45,9 @@ | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | | `@deepseek-ai/dsh-experimental-tool-subagent-oar` | `subagent_oar` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `parent subagent catalog and completion notices` | - | subagent_oar 工具在为外部提供方配置的别名之间选择。本目录展示 Codex 和 Claude Code 别名;每个部署自行选择提供方。原生模型和权限设置由所选提供方的配置决定。 | | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`ctx.sessionProjections`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 加载时的 `toolName` 选择委派名称(默认为 `subagent`);`modelSelection` 增加可选路由字段。`modelDiscovery` 默认关闭,每个作用域由一个实例注册共享发现工具。Web 与 Desktop preset 启用 spawn 发现和路由字段,并为 `subagent_fork` 保持路由字段关闭。Host 设置服务为每个新顶层 Session 记录授权;子 Session 继承该授权。没有已记录策略时,发现和显式选择只允许当前父级路由。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 | -| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包的一个实例注册 `send_message`、`interrupt_agent` 和 `list_agents`。 | +| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message`、`wait_agent` | `ctx.tools`、`ctx.subagents`、`ctx.agents` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | -| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/task`、`agent/inbox/spliced`、`user/message on inbox claim`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | +| `@deepseek-ai/dsh-tool-agent-team` | `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `ctx.subagents`, `an exact live Team member Agent` | `tool/call`, `team/task`, `tool/result` | - | 四个任务板工具共享 subagent 树的根 Session。在根 Session 开启 Team 模式,会在标准 subagent 委派和控制工具之外增加任务板指导。标准 subagent 控制工具提供必需身份,不受 Team 模式影响。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | 读取或改变调用方 Session 的目录。已有 shell 和进程保留自己的目录;原始元数据和权限根目录保持不变。 | | `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | 在 Web 和 Desktop 上通过变更请求与工作树组合包启用;其他 profile 可以选择独立组合包。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 | @@ -2045,7 +2045,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `list_subagent_models` -查询 subagent 可用的模型,不更改当前 Agent。无参数:提供方;`provider`:允许的模型;`provider` + `model`:该模型及其推理强度。选择子级提供方和模型前先在此查询选项,再将返回的 id 用于委派。 +查询允许为 subagent 显式选择的路由。无参数:提供方;`provider`:允许的模型;`provider` + `model`:该模型及其推理强度。选择子级提供方和模型前先在此查询选项,再将返回的 id 用于委派。 ```json { @@ -2067,7 +2067,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。运行时会在任务结束时通知你。子代理通过 `send_message` 汇报结果;你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。 ```json { @@ -2079,7 +2079,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "description": { "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." + "description": "A short (3-5 word) description of the delegated task, for display. Use the language of the user's current request." }, "prompt": { "type": "string", @@ -2124,21 +2124,12 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `list_agents` -按持久 ID 和标签列出调用方目录中可继续的直接子代理。使用此工具回忆已启动的子代理,不要轮询完成状态;完成时会收到通知。状态来自在线注册表:running 表示当前正在工作;inactive 表示当前没有执行轮次,无论子代理已加载还是需要恢复,并不表示任务完成、成功、失败或正在等待其他代理。`send_message` 可在最近的步骤边界引导正在运行的子代理,或启动、恢复非活跃子代理的轮次;任何状态下的直接子级都可作为 `send_message` 的候选目标。列表快照不保证投递成功,`send_message` 仍进行权威检查,且可能失败。 +列出整棵 subagent 树:根代理和所有可达的可继续后代,包括调用方自身。行包含 id、描述和状态;后代行包含父级 id 和相对根的深度。可以向树中列为 running 或 inactive 的任意其他代理发送消息。running 表示正在工作;inactive 表示当前没有轮次在执行,不表示任务完成、成功或失败。自身仍在线时,直属子代理结束会收到通知,无需反复查询其状态。使用 send_message 继续对话。 ```json { "type": "object", - "properties": { - "scope": { - "type": "string", - "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.", - "enum": [ - "children", - "descendants" - ] - } - } + "properties": {} } ``` @@ -2146,7 +2137,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 ### `send_message` -根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 idle,消息会启动一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。 +向代理发消息。工作中的代理在下一步骤接收消息,空闲代理用消息启动新轮次。返回投递确认。 ```json { @@ -2154,7 +2145,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 "properties": { "agent_id": { "type": "string", - "description": "The agent id of your direct continuable child, or your direct parent when you are a resident continuable child." + "description": "The agent id of another member of your subagent tree, as shown by list_agents." }, "message": { "type": "string", @@ -2170,7 +2161,25 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 来源:[`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) -这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包的一个实例注册 `send_message`、`interrupt_agent` 和 `list_agents`。 +### `wait_agent` + +等待下一步可处理的输入,包括 Agent 消息、完成通知和用户引导。已有此类待处理输入时立即返回。 + +```json +{ + "type": "object", + "properties": { + "timeout_ms": { + "type": "integer", + "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." + } + } +} +``` + +来源:[`packages/subagent/tool-subagent-control/src/wait-agent.ts`](../packages/subagent/tool-subagent-control/src/wait-agent.ts) + +共享控制工具发现并联系 subagent 树中的 Agent,中断有权控制的后代,并等待调用者收件箱中的输入。绑定提供方的 `tool-subagent` 实例提供委派工具。 @@ -2245,108 +2254,9 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 - + -## `@deepseek-ai/dsh-experimental-tool-agent-team` - -### `interrupt_agent` - -中断一名 teammate 的当前 turn,同时保留其待处理 inbox。仅 Team Lead 可用。 - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Teammate target returned by spawn_teammate or list_agents." - } - }, - "required": [ - "target" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `list_agents` - -列出 Lead 与所有持久 teammate,以及可用于寻址的 target 和当前可用状态。inactive 表示没有轮次在执行,不表示任务结果。provisioning 与 failed 描述成员创建状态。 - -```json -{ - "type": "object", - "properties": {} -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `send_message` - -向另一名 Team member 发送一条消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。 - -```json -{ - "type": "object", - "properties": { - "target": { - "type": "string", - "description": "Member target returned by spawn_teammate or list_agents, including lead." - }, - "message": { - "type": "string", - "description": "Self-contained message for the target." - } - }, - "required": [ - "target", - "message" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `spawn_teammate` - -创建一名具名、持久的 teammate。只有 Team Lead 可以调用此工具。 - -```json -{ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Unique lower-kebab-case teammate name." - }, - "description": { - "type": "string", - "description": "Short description of the delegated responsibility." - }, - "prompt": { - "type": "string", - "description": "Complete initial task for the teammate." - }, - "context": { - "type": "string", - "description": "fresh starts without Lead history; fork inherits completed Lead turns. Defaults to fresh.", - "enum": [ - "fresh", - "fork" - ] - } - }, - "required": [ - "name", - "description", - "prompt" - ] -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +## `@deepseek-ai/dsh-tool-agent-team` ### `team_task_create` @@ -2386,7 +2296,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_get` @@ -2407,7 +2317,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_list` @@ -2428,7 +2338,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner." + "description": "Optional exact ownerId filter; use unowned for tasks without an owner." }, "ready": { "type": "boolean", @@ -2446,7 +2356,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) ### `team_task_update` @@ -2502,7 +2412,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 }, "owner": { "type": "string", - "description": "Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign." + "description": "Agent id of the root or any continuable descendant for root-only reassign; omit to unassign." } }, "required": [ @@ -2513,28 +2423,9 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录 } ``` -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -### `wait_agent` - -等待本次调用开始后下一次 teammate 状态、消息或共享任务变更。它绝不会唤醒 inactive member;若没有其他 member 正在 running 或 provisioning,则立即返回 noProgress。唤醒或超时后应重新列出状态,而不是轮询。 - -```json -{ - "type": "object", - "properties": { - "timeout_ms": { - "type": "integer", - "description": "Wait duration in milliseconds, from 10000 through 3600000. Defaults to 30000." - } - } -} -``` - -来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts) - -这 10 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 +来源:[`packages/team/tool-agent-team/src/index.ts`](../packages/team/tool-agent-team/src/index.ts) +四个任务板工具共享 subagent 树的根 Session。在根 Session 开启 Team 模式,会在标准子代理委派和控制工具之外增加任务板指导。标准子代理控制工具提供必需身份,不受 Team 模式影响。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml index 0f10d46677..fee15bfbc4 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 9968decf3dd340af zh: 736a32caac4b5103 /agent-teams-messages-use-target-inboxes/migration: - en: 7ffd442bfd55769e - zh: ffb11a143f11ce7f + en: a4b8999943c76cc4 + zh: 042927b5c857c8d5 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md index 974c1aa13c..d2e03eacd5 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md @@ -18,4 +18,4 @@ The `maxPendingMessagesPerMember` configuration key is removed. Historical `team 1. Before upgrading, let pending Team messages finish and confirm that their targets received them. After upgrading, explicitly resend any required undelivered message; check the target's history first to avoid duplicates. Do not edit historical Session records. 2. Remove `maxPendingMessagesPerMember` from the `@deepseek-ai/dsh-experimental-agent-team` plugin configuration in your `cordis.yml` or overlays. There is no replacement Team queue limit; `maxMessageBytes` still bounds each complete sender-framed message. 3. Update any consumer of `send_message` results to read `sent` instead of message IDs or delivery statuses, and handle send errors explicitly. Do not treat success as completed target work. The `ctx.agentTeams.sendMessage()` service still returns `messageId`. -4. Start your Team-enabled profile, send a message to another member, and confirm that the tool returns `{ "sent": true }` and that the target receives it. Consult the [package reference](../../../../packages/experimental/agent-team/README.md#use-this-package) for delivery semantics. +4. Start your Team-enabled profile, send a message to another member, and confirm that the tool returns `{ "sent": true }` and that the target receives it. Consult the [package reference](../../../../packages/team/agent-team/README.md#use-this-package) for delivery semantics. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md index c10791fbf7..1f5014e0c7 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md @@ -18,4 +18,4 @@ description: "Agent Teams 移除待投递消息数量限制,并停止投递历 1. 升级前,让待投递的 Team 消息完成投递,并确认目标已收到。升级后,显式重发仍需处理的未投递消息;先检查目标历史,避免重复。不要编辑历史 Session 记录。 2. 从 `cordis.yml` 或覆盖配置中 `@deepseek-ai/dsh-experimental-agent-team` 插件的配置移除 `maxPendingMessagesPerMember`。没有替代的 Team 队列限制;`maxMessageBytes` 仍限制包含发送者标识的完整消息大小。 3. 更新所有读取 `send_message` 结果的调用方,改为读取 `sent` 而非消息 ID 或投递状态,并显式处理发送错误。不要把发送成功视为目标工作已完成。`ctx.agentTeams.sendMessage()` 服务仍返回 `messageId`。 -4. 启动启用 Team 的 profile,向其他成员发送消息,确认工具返回 `{ "sent": true }` 且目标收到消息。投递语义参见[包参考](../../../../packages/experimental/agent-team/README.zh.md#use-this-package)。 +4. 启动启用 Team 的 profile,向其他成员发送消息,确认工具返回 `{ "sent": true }` 且目标收到消息。投递语义参见[包参考](../../../../packages/team/agent-team/README.zh.md#use-this-package)。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.i18n.yaml new file mode 100644 index 0000000000..2374c0b447 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md +/: + en: ced4d23952f772d5 + zh: f8e7e8c069266fa1 +/agent-runtime-ownership-uses-owneragent: + en: 8a1164f53b3627da + zh: 19fae50e43c4dca4 +/agent-runtime-ownership-uses-owneragent/change: + en: 88457ac32ffc2c7d + zh: 05ea7db957db119b +/agent-runtime-ownership-uses-owneragent/migration: + en: d25ad901f4601369 + zh: 96b556991249635b diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md new file mode 100644 index 0000000000..4648667d9e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Agent creation and resume options rename parentAgent to ownerAgent for runtime ownership." +--- + +# Agent runtime ownership uses `ownerAgent` + +English | [中文](guide.zh.md) + +## Change + +`CreateAgentOptions` and `ResumeAgentOptions` in `@deepseek-ai/dsh-agent` replace `parentAgent` with `ownerAgent`. This affects plugins and custom factories calling `ctx.agents.create()` or `ctx.agents.resume()`. The field selects the live Agent that owns the runtime instance; omitting it creates a runtime root. + +Session creation lineage remains in `meta.parentSession`. For a nested subagent, the runtime owner can be the tree root while the direct creator is another descendant. Persisted Session fields and formats are unchanged. + +## Migration + +1. Rename the `parentAgent` option to `ownerAgent` in creation and resume calls, and update custom `AgentFactory` implementations that read or forward it. Pass the same Agent as before. +2. Keep `meta.parentSession` and stored `parentSession` values unchanged. Do not substitute the runtime owner for the direct creator. +3. Typecheck consumers against the updated package, then verify that `ctx.agents.isOwnedBy(id, owner)` and `ctx.agents.roots()` report the same runtime relationships after creation and resume. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.zh.md new file mode 100644 index 0000000000..b1e36efd9f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-runtime-owner/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Agent 创建和恢复选项将表示运行归属的 parentAgent 改名为 ownerAgent。" +--- + +# Agent 运行归属使用 `ownerAgent` + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-agent` 中的 `CreateAgentOptions` 和 `ResumeAgentOptions` 将 `parentAgent` 替换为 `ownerAgent`。这影响调用 `ctx.agents.create()` 或 `ctx.agents.resume()` 的插件与自定义工厂。该字段指定拥有运行实例的在线 Agent;省略时创建运行时根。 + +Session 创建来源仍由 `meta.parentSession` 记录。嵌套子代理的运行归属者可以是树根,而直接创建者是另一个后代。持久化 Session 字段和格式保持不变。 + +## 迁移 + +1. 将创建和恢复调用中的 `parentAgent` 选项改名为 `ownerAgent`,并更新读取或透传该字段的自定义 `AgentFactory` 实现。继续传入原来的 Agent。 +2. 保持 `meta.parentSession` 和存储的 `parentSession` 值不变。不要用运行归属者替换直接创建者。 +3. 针对更新后的包对消费者运行类型检查,再验证创建和恢复后的 `ctx.agents.isOwnedBy(id, owner)` 与 `ctx.agents.roots()` 仍报告相同的运行归属关系。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml new file mode 100644 index 0000000000..1b127d0026 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md +/: + en: 1f073189776760d8 + zh: cfbb0403aea96f84 +/agent-teams-uses-subagents-and-session-ids: + en: 703c7766dd80e1d1 + zh: 157c67164c1cbc7d +/agent-teams-uses-subagents-and-session-ids/change: + en: d387c95a87d1bd6a + zh: 69dd000fbff0b6fa +/agent-teams-uses-subagents-and-session-ids/migration: + en: dcc698d29a4677f0 + zh: 2af5cea905ad4371 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md new file mode 100644 index 0000000000..dedc7025fb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.md @@ -0,0 +1,31 @@ +--- +kind: upgrade-guide +description: "Agent Teams uses the standard subagent tools and Session ids; Team-specific creation, messaging, listing, interruption, and their configuration keys are removed." +--- + +# Agent Teams uses subagents and Session ids + +English | [中文](guide.zh.md) + +## Change + +Descriptions label responsibilities; only Session ids identify recipients. + +- `spawn_teammate` and the Team-scoped `send_message`, `list_agents`, and `interrupt_agent` are removed. The standard control tools use `agent_id`, not `target`. Members of one root tree can message each other across branches by id; interruption remains ancestor-only. +- `list_agents` takes no arguments and lists the entire tree. Remove scripted `scope` arguments. Rows use `kind: root`, `kind: child`, or `kind: diagnostic`; descendants include parent ids and root-relative depth. +- Team supplies four task tools and policy; controls supply required identity. `wait_agent` moves to ordinary subagent controls and waits for the caller’s next-step inbox, including pending input. Status changes and unrelated inboxes do not wake it; `noProgress` is removed. Timeout bounds/default remain unchanged. +- `agent-team` rejects `maxMembers`, `maxMessageBytes`, and `disposalTimeoutMs`; `tool-agent-team` rejects `interrupt`, `freshProvider`, and `forkProvider`. +- `ctx.agentTeams` removes creation, messaging, interruption, roster, membership, and wait APIs. `TeamMembership`, `TeamMemberView`, spawn/send request/result types, and `TeamWaitResult` are removed. Task ownership uses `ownerId`, including the root’s Session id. +- Runtime instances share root ownership without changing creation ancestry. Member settlement does not dispose descendants. Messages restore only their target. Hosts close the tree when its root closes; members obtain required results explicitly. +- Cold resume uses saved presets and permissions, not the creator’s current generation. Missing presets fail restoration; historical targets without one use host composition plus descriptor overrides. Lifecycle dispatch uses the root scope while retaining the direct creator in `parentSessionId`. Browser continuation requires the root online; failure uses `subagent/delivery-unavailable`. +- Historical Team roster and mailbox records remain readable. Historical names do not resolve message recipients or task owners. Subagent catalog payloads are unchanged. +- The Web Team panel shows tasks only; subagent navigation owns member browsing. The wire `agentTeam.members` field and `TeamMemberProjection` are removed; persisted roster state still decodes. Task commits do not end waits, deliver messages, or start idle Agents. Message another Agent when it needs to act on task changes. + +## Migration + +1. Move `interrupt: false` from `tool-agent-team` to `tool-subagent-control`. Remove other obsolete keys and any patch rows that disable subagent tools solely for Team support. +2. Replace `spawn_teammate({ name, description, prompt, context })` with `subagent({ description, prompt })`, or `subagent_fork` for inherited conversation history. Save the returned agent id. +3. Use `send_message({ agent_id, message })` and `interrupt_agent({ agent_id })`. Discover all Team member ids with `list_agents`. +4. Pass member Session ids to task `owner` fields and read `ownerId` in results. Use the Lead's Session id instead of the alias `lead`. +5. Replace `spawnTeammate`, `sendMessage`, and `interrupt` with `ctx.subagents.startActivation`, `sendMessage`, and `interrupt`. Replace `membership`/`tryMembership` with `rootOf`, and `listMembers` with `listDescendants`. Replace `waitForChange` with `wait_agent`, then re-read tasks. +6. SDK `session/resume` rejects subagent sessions. Resume the root Session instead. Verify navigation, nested task assignment, and that messages end inbox waits while task commits do not. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md new file mode 100644 index 0000000000..177831b543 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/agent-team-subagents/guide.zh.md @@ -0,0 +1,31 @@ +--- +kind: upgrade-guide +description: "Agent Teams 使用标准 subagent 工具和 Session id;移除 Team 专属创建、消息、列举、中断操作及其配置键。" +--- + +# Agent Teams 使用子代理和 Session id + +[English](guide.md) | 中文 + +## 变更 + +描述标注职责;仅 Session id 标识接收者。 + +- 移除 `spawn_teammate` 以及 Team 作用域的 `send_message`、`list_agents` 和 `interrupt_agent`。标准控制工具使用 `agent_id` 而非 `target`。同一根下的成员可以通过 id 跨分支互发消息;中断仍只允许祖先执行。 +- `list_agents` 不接受参数,列出整棵树。移除脚本中的 `scope` 参数。行使用 `kind: root`、`kind: child` 或 `kind: diagnostic`;后代包含父 id 和相对根的深度。 +- Team 提供四个任务工具和策略;控制工具提供必需身份。`wait_agent` 移至普通 subagent 控制工具,等待调用者的下一步收件箱,包括待处理输入。状态变化和其他收件箱不会唤醒它;移除 `noProgress`。超时范围和默认值不变。 +- `agent-team` 拒绝 `maxMembers`、`maxMessageBytes` 和 `disposalTimeoutMs`;`tool-agent-team` 拒绝 `interrupt`、`freshProvider` 和 `forkProvider`。 +- `ctx.agentTeams` 移除创建、通信、中断、名册、成员关系和等待 API。`TeamMembership`、`TeamMemberView`、spawn/send 请求和结果类型以及 `TeamWaitResult` 被移除。任务归属使用 `ownerId`,包括根的 Session id。 +- 运行时实例共享根所有权,不改变创建祖先关系。成员收尾不释放后代。消息仅恢复目标。根关闭时 host 关闭整棵树;成员显式获取所需结果。 +- 冷恢复使用已保存的 preset 和权限,而非创建者当前代次。缺失 preset 会导致恢复失败;历史目标没有保存 preset 时,使用 host 组合及描述符覆盖。生命周期分派使用根作用域,同时在 `parentSessionId` 中保留直接创建者。浏览器继续执行需要根在线;失败使用 `subagent/delivery-unavailable`。 +- 历史 Team 名册和邮箱记录仍可读取。其中的名字仅作为历史数据,不用于解析消息收件人或任务所有者。Subagent catalog 载荷不变。 +- Web Team 面板仅显示任务;subagent 导航负责浏览成员。线上 `agentTeam.members` 字段与 `TeamMemberProjection` 被移除;持久化成员状态仍可解码。任务提交不结束等待、不投递消息,也不启动空闲 Agent。需要另一个 Agent 处理任务变更时,应发送消息。 + +## 迁移 + +1. 将 `interrupt: false` 从 `tool-agent-team` 移到 `tool-subagent-control`。删除其余废弃键,以及仅为支持 Team 而禁用 subagent 工具的 patch 行。 +2. 将 `spawn_teammate({ name, description, prompt, context })` 替换为 `subagent({ description, prompt })`;继承对话历史时使用 `subagent_fork`。保存返回的 agent id。 +3. 使用 `send_message({ agent_id, message })` 和 `interrupt_agent({ agent_id })`。通过 `list_agents` 查询所有 Team 成员的 id。 +4. 任务的 `owner` 字段传入成员 Session id,结果读取 `ownerId`。以 Lead 的 Session id 取代 `lead` 别名。 +5. 将 `spawnTeammate`、`sendMessage` 和 `interrupt` 替换为 `ctx.subagents.startActivation`、`sendMessage` 和 `interrupt`。将 `membership`/`tryMembership` 替换为 `rootOf`,将 `listMembers` 替换为 `listDescendants`。将 `waitForChange` 替换为 `wait_agent`,随后重新读取任务。 +6. SDK `session/resume` 拒绝子代理会话。改为恢复根 Session。验证导航、嵌套任务分配,以及消息结束收件箱等待而任务提交不会。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.i18n.yaml new file mode 100644 index 0000000000..badcbe4e11 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md +/: + en: 2b713df685f8b593 + zh: 4e185e636a3b5d99 +/select-agent-teams-per-session: + en: 9e82418f46f6275b + zh: d1282eec79b37b59 +/select-agent-teams-per-session/change: + en: 3ba5920179726841 + zh: 940e0d956a523dbc +/select-agent-teams-per-session/migration: + en: 7b6dc660602d9c3b + zh: 8f8459198300aa46 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md new file mode 100644 index 0000000000..8f275535b6 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Agent Teams defaults off for new Sessions and uses a saved per-Session selection." +--- + +# Select Agent Teams per Session + +English | [中文](guide.zh.md) + +## Change + +Agent Teams ships with base-backed profiles; new Sessions default to off. The Plugins page has no separate Agent Teams switch. The root Session’s selection controls task tools and Team guidance throughout its subagent tree. The model and reasoning menu offers **Use Agent Teams**, and the root conversation’s Subagent header control opens members above shared tasks in one panel while enabled. Child conversations retain navigation without a shared task board. + +Disabling Team retains tasks and subagents. Admitted task operations finish; queued operations check the current mode when they start. The selection survives restart. A historical task board without a saved selection remains enabled. The required `team/mode` event records selections; older readers reject this unfamiliar event. See the [persistence acknowledgement](../../../persistence-changes/2026-10-19-session-team-mode.md). + +## Migration + +1. Start each existing profile once. Profile loading removes the retired `@deepseek-ai/dsh-experimental-agent-team-profile` bundle selection; the base and Web bundles supply its plugins. Keep `agent-team`, `tool-agent-team`, and `ui-agent-team` row overrides. Custom compositions replace package names `@deepseek-ai/dsh-experimental-agent-team`, `@deepseek-ai/dsh-experimental-tool-agent-team`, and `@deepseek-ai/dsh-experimental-client-ui-agent-team` with `@deepseek-ai/dsh-agent-team`, `@deepseek-ai/dsh-tool-agent-team`, and `@deepseek-ai/dsh-client-ui-agent-team`. +2. Toggle **Use Agent Teams** in the root conversation’s model and reasoning menu. The `/team on` and `/team off` commands are removed. +3. Verify that enabling shows shared tasks in the root conversation below the Subagent tree in the same panel and exposes four task tools. Reload the Session to confirm the saved selection. Re-enabling after disabling restores the existing tasks. + +Client plugin consumers replace `TeamActionProps` with `TeamBoardProps`; task content registers in `conversation.session.header.actions.subagent.footer` instead of the header actions slot. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md new file mode 100644 index 0000000000..65f149a2f2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md @@ -0,0 +1,22 @@ +--- +kind: upgrade-guide +description: "Agent Teams 在新 Session 中默认关闭,并使用按 Session 保存的选择。" +--- + +# 按 Session 选择智能体团队 + +[English](guide.md) | 中文 + +## 变更 + +Agent Teams 随基于 base 的 profile 提供;新 Session 默认关闭。插件页不再提供独立的智能体团队开关。根 Session 的选择控制整棵子代理树中的任务工具和 Team 提示词。模型与推理等级菜单提供**使用智能体团队**开关,开启后通过根会话的 Subagent 页头入口,在同一面板中查看上方成员与下方共享任务。子会话保留导航,不显示共享任务板。 + +关闭 Team 保留任务和子代理。已经进入执行的任务操作继续完成;排队中的操作在开始时检查当前模式。选择在重启后仍有效。没有保存选择的历史任务板保持开启。必读事件 `team/mode` 记录选择;旧读取器会拒绝该未知事件。参见[持久化变更确认](../../../persistence-changes/2026-10-19-session-team-mode.zh.md)。 + +## 迁移 + +1. 启动每个已有 profile 一次。加载时会移除已停用的 `@deepseek-ai/dsh-experimental-agent-team-profile` 组合选项;base 与 Web 组合提供对应插件。保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 行覆盖。自定义组合将包名 `@deepseek-ai/dsh-experimental-agent-team`、`@deepseek-ai/dsh-experimental-tool-agent-team`、`@deepseek-ai/dsh-experimental-client-ui-agent-team` 分别替换为 `@deepseek-ai/dsh-agent-team`、`@deepseek-ai/dsh-tool-agent-team`、`@deepseek-ai/dsh-client-ui-agent-team`。 +2. 在根会话的模型与推理等级菜单中切换**使用智能体团队**。`/team on` 与 `/team off` 命令已移除。 +3. 在根会话中确认开启后通过 Subagent 入口在树下方查看共享任务板,并提供四个任务工具。重新加载 Session,确认选择已保存。关闭后再开启会恢复已有任务。 + +Client 插件消费者将 `TeamActionProps` 替换为 `TeamBoardProps`;任务内容注册到 `conversation.session.header.actions.subagent.footer`,而非页头操作插槽。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.i18n.yaml new file mode 100644 index 0000000000..12139cd087 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md +/: + en: b087162d9a24f772 + zh: a32f167c3529fb1c +/subagent-activation-options: + en: 20a5b01ef11bff72 + zh: 919de21bc865ee9e +/subagent-activation-options/change: + en: 7cc2554992e98c53 + zh: 434540bcd037d1f5 +/subagent-activation-options/migration: + en: 2243d08661eb457c + zh: a928a8e7fb3515ff diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md new file mode 100644 index 0000000000..3f2a202b0c --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "SubagentActivationSpec accepts task options directly instead of a nested request object." +--- + +# Subagent activation options + +English | [中文](guide.zh.md) + +## Change + +Plugin code calling `ctx.subagents.startActivation()` must pass `prompt`, `parent`, and optional execution settings directly on `SubagentActivationSpec`. The nested `request` field is removed. Type names, provider requests, cancellation, and result delivery are unchanged. + +## Migration + +1. In each `startActivation()` call, move the fields from `request` to the outer object. For an existing task object, replace `request: task` with `...task`; keep the activation's `label` and `signal` after the spread so they take precedence. +2. Replace reads such as `spec.request.prompt` with `spec.prompt`. Keep backend `SubagentStartRequest` implementations unchanged. +3. Typecheck the plugin and run its delegation tests to verify the updated calls. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.zh.md new file mode 100644 index 0000000000..32c2785bcc --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-activation-options/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "SubagentActivationSpec 直接接受任务选项,不再使用嵌套的 request 对象。" +--- + +# 子代理 activation 选项 + +[English](guide.md) | 中文 + +## 变更 + +调用 `ctx.subagents.startActivation()` 的插件代码必须将 `prompt`、`parent` 和可选执行设置直接放在 `SubagentActivationSpec` 上。嵌套的 `request` 字段已移除。类型名称、提供方请求、取消和结果投递保持不变。 + +## 迁移 + +1. 在每个 `startActivation()` 调用中,将 `request` 内的字段移到外层对象。已有任务对象时,将 `request: task` 替换为 `...task`;将 activation 的 `label` 和 `signal` 放在展开操作之后,以确保它们优先生效。 +2. 将 `spec.request.prompt` 等读取替换为 `spec.prompt`。保持后端的 `SubagentStartRequest` 实现不变。 +3. 对插件运行类型检查和委派测试,验证更新后的调用。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml new file mode 100644 index 0000000000..464cd3ccbe --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md +/: + en: 6955b11d6f698e4c + zh: 8a8d4d6edd5dea0c +/subagent-service-method-removal: + en: 102f43348f7c8eb0 + zh: be09ff117c31ada1 +/subagent-service-method-removal/change: + en: 8cd7f93df401c417 + zh: 27313127c2059e6b +/subagent-service-method-removal/migration: + en: f0f0f921f284efec + zh: 8b9e88e9412282c2 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md new file mode 100644 index 0000000000..a1eaad618b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The subagent service removes drainChildren, listChildren, and list." +--- + +# Subagent service method removal + +English | [中文](guide.zh.md) + +## Change + +`ctx.subagents` removes `drainChildren(parent, childIds)`, `listChildren(parentSessionId, signal?)`, and `list()`. This affects plugins that release resident children by id, read direct-child catalogs through the service, or enumerate registered provider names. + +## Migration + +1. Retain the activation returned by `startActivation()` and await its `dispose()` to release that exact execution. An old activation cannot release a later execution of the same Session. There is no replacement for releasing the current resident child by id; use `drainDescendants([root])` only when closing the entire tree. `interrupt()` stops current work but preserves a local Activation. +2. For direct-child entries, use `ctx.sessionQuery.observeSession(parentSessionId, { signal })`, read `observation.projections?.values.subagentCatalog`, and dispose the observation with `using`. Load Session Query and the subagent service, and treat a missing catalog projection as unavailable. Keep `listDescendants()` for recursive discovery; its rows differ from direct catalog entries. +3. Use `getProvider(name)` to check known provider names. The service no longer enumerates all registered names. +4. Rebuild plugins against the updated service declarations. Verify registration and unload checks, exact-activation disposal, and catalog observation cleanup. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md new file mode 100644 index 0000000000..ce24ef1b90 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-service-methods/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "子代理服务移除 drainChildren、listChildren 和 list。" +--- + +# 子代理服务方法移除 + +[English](guide.md) | 中文 + +## 变更 + +`ctx.subagents` 移除 `drainChildren(parent, childIds)`、`listChildren(parentSessionId, signal?)` 和 `list()`。这会影响按 id 释放驻留子代理、通过服务读取直接子级目录或枚举已注册 provider 名称的插件。 + +## 迁移 + +1. 保留 `startActivation()` 返回的 activation,并等待其 `dispose()` 完成以释放那一次执行。旧 activation 不能释放同一 Session 后来恢复的执行。按 id 释放当前驻留子代理没有替代方法;仅在关闭整棵树时使用 `drainDescendants([root])`。`interrupt()` 停止当前工作,但保留本地 Activation。 +2. 读取直接子级条目时,使用 `ctx.sessionQuery.observeSession(parentSessionId, { signal })`,读取 `observation.projections?.values.subagentCatalog`,并通过 `using` 释放观察对象。加载 Session Query 和子代理服务,并将缺失的目录投影视为不可用。递归发现继续使用 `listDescendants()`;它返回的行与直接目录条目不同。 +3. 使用 `getProvider(name)` 检查已知 provider 名称。服务不再枚举全部已注册名称。 +4. 根据更新后的服务声明重新构建插件,验证注册和卸载检查、指定 activation 的释放以及目录观察对象的清理。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.i18n.yaml new file mode 100644 index 0000000000..1ef94f1d8f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md +/: + en: 9d77440fbd1ac5ee + zh: e51db37880a28684 +/subagent-user-controls-address-the-target-session: + en: 8f5fcee305e3a5d7 + zh: c6bb2c762cd2b86d +/subagent-user-controls-address-the-target-session/change: + en: 0a309d3a2a123b46 + zh: 416f075bfdb8664a +/subagent-user-controls-address-the-target-session/migration: + en: f3a61ba7c3a5110e + zh: 95c88d6f9d685fd4 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md new file mode 100644 index 0000000000..9080ef5f66 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.md @@ -0,0 +1,26 @@ +--- +kind: upgrade-guide +description: "Subagent user controls address only the target Session, rename the interrupt endpoint, and require target Session permissions instead of global permissions." +--- + +# Subagent user controls address the target Session + +English | [中文](guide.zh.md) + +## Change + +Browser and custom Remote callers send subagent prompts without `parentSessionId` or `mode`. `subagents/interruptByParent(childSessionId, parentSessionId, mode)` is replaced by `subagents/interruptByUser(childSessionId)`. Creation ancestry, history addresses, completion recipients, and persisted Session data are unchanged. + +Both operations name the child as a `session` access resource. Non-owner policies must grant the corresponding action on that Session; a `global` binding or a binding on its parent does not grant access. Owner behavior is unchanged. Model messages remain limited to one subagent tree, and model interruption remains ancestor-only. + +The same-process `SubagentInterruptAuthority` user variant is `{ kind: 'user' }`. The published `@deepseek-ai/dsh-subagent/internal` entry point is removed, including its host prompt adapters and tool identity markers. Remote authorization is performed by the Gateway. + +Old prompt payloads with extra fields inside `request` can decode on the new Host, subject to its new access rules. Old interrupt calls fail because the endpoint is removed. New callers require the new Host. Upgrade custom clients and hosts together; the bundled Web client is delivered with its Host. + +## Migration + +1. Remove `parentSessionId` and `mode` from subagent prompt requests. Keep `childSessionId`, `requestId`, `delivery`, `content`, and optional `clientTimeZone`. +2. Replace calls to `interruptByParent(childId, parentId, 'continuable')` with `interruptByUser(childId)`. +3. Update non-owner policies to grant `subagents/prompt` and/or `subagents/interruptByUser` on `{ kind: 'session', id: childId }`. Parent-to-child grants remain an explicit policy decision. +4. Remove `parentSessionId` from `{ kind: 'user' }` interrupt authorities. Replace imports from `@deepseek-ai/dsh-subagent/internal`: authenticated user prompts use `subagents/prompt`; model-authored messages use `ctx.subagents.sendMessage()`. There is no public arbitrary-source host prompt adapter. Custom tools own their instructions through tool descriptions and `systemPrompt.context()`, without identity markers. Keep parent ids in history addresses and creation records. +5. Verify that a target-authorized user can send and interrupt, while a user authorized only for the parent or global resource is refused before any input or cancellation is admitted. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md new file mode 100644 index 0000000000..8b9d8fab50 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/subagent-user-controls/guide.zh.md @@ -0,0 +1,26 @@ +--- +kind: upgrade-guide +description: "子代理用户控制仅按目标 Session 寻址,重命名中断端点,并以目标 Session 权限替代 global 权限。" +--- + +# 子代理用户控制按目标 Session 寻址 + +[English](guide.md) | 中文 + +## 变更 + +浏览器和自定义 Remote 调用方发送子代理提示词时,不再提供 `parentSessionId` 或 `mode`。`subagents/interruptByParent(childSessionId, parentSessionId, mode)` 替换为 `subagents/interruptByUser(childSessionId)`。创建祖先、历史地址、完成通知接收者和持久化 Session 数据保持不变。 + +两个操作都将子会话声明为 `session` 访问资源。非 owner 策略必须授予该 Session 上对应操作的权限;`global` 绑定或父会话上的绑定不授予访问权。owner 行为保持不变。模型消息仍限于同一棵子代理树,模型中断仍仅允许祖先发起。 + +同进程 `SubagentInterruptAuthority` 的用户分支为 `{ kind: 'user' }`。已发布的 `@deepseek-ai/dsh-subagent/internal` 入口已删除,包括宿主 prompt 适配器和工具身份标记。远程授权由 Gateway 执行。 + +旧提示词载荷在 `request` 内多带字段时,可以在新 Host 上解码,但须满足新的访问规则。旧中断调用因端点已移除而失败。新调用方要求新 Host。自定义客户端和宿主应一起升级;内置 Web 客户端随 Host 一起交付。 + +## 迁移 + +1. 从子代理提示词请求中移除 `parentSessionId` 和 `mode`。保留 `childSessionId`、`requestId`、`delivery`、`content` 和可选的 `clientTimeZone`。 +2. 将 `interruptByParent(childId, parentId, 'continuable')` 调用替换为 `interruptByUser(childId)`。 +3. 更新非 owner 策略,在 `{ kind: 'session', id: childId }` 上授予 `subagents/prompt` 和/或 `subagents/interruptByUser` 权限。父会话到子会话的授权仍由策略显式决定。 +4. 从 `{ kind: 'user' }` 中断授权中移除 `parentSessionId`。替换 `@deepseek-ai/dsh-subagent/internal` 的导入:经过认证的用户 prompt 使用 `subagents/prompt`;模型编写的消息使用 `ctx.subagents.sendMessage()`。不再提供允许任意来源的公开宿主 prompt 适配器。自定义工具通过工具描述和 `systemPrompt.context()` 提供说明,无需身份标记。历史地址和创建记录仍保留父级 id。 +5. 验证获准操作目标的用户能够发送和中断,而仅获准操作父会话或 global 资源的用户在输入或取消被接纳前遭到拒绝。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml index f8584a6842..7e4e4247a2 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 1552866718133b10 zh: 1c760c5c2d5cfc0b /authorize-the-computer-use-helper/migration: - en: a79e5995415ff456 - zh: 6f818a092e53af2c + en: 2a28385e24468734 + zh: 725c1856a604bc0c diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md index c913488785..9e57c7c569 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.md @@ -13,7 +13,7 @@ macOS Computer Use runs as `DSH Computer Use.app`, launched through LaunchServic ## Migration -1. Computer Use now installs on demand. In **Plugins → Add plugin**, install `@deepseek-ai/dsh-cua-bundle` and enable it; profiles that previously selected the shipped copy need this installation. Restart DSH if requested. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. +1. Restart DSH after upgrading the Computer Use package. In the Computer Use plugin page, grant Accessibility and Screen Recording access to `DSH Computer Use.app` when macOS requests it. 2. Remove an obsolete `helperPath` override or `DSH_CUA_HELPER` value to use automatic preparation. For a custom build, point it to the executable at `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` inside the complete application bundle. 3. Confirm that the permission page identifies `DSH Computer Use.app` and both permissions are allowed before resuming native application tasks. Target-application consent remains required. 4. If a terminal or Desktop launcher was granted these permissions only for Computer Use, revoke those obsolete grants in System Settings. Authorizing the helper does not revoke other applications’ existing permissions; programs the model runs still follow their launcher’s system permissions and the DSH sandbox policy. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md index f25a4b805d..a6c3a2f634 100644 --- a/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.3/computer-use-helper-permissions/guide.zh.md @@ -13,7 +13,7 @@ macOS Computer Use 通过 LaunchServices 以 `DSH Computer Use.app` 运行。终 ## 迁移 -1. Computer Use 改为按需安装。在“插件 → 添加插件”中安装 `@deepseek-ai/dsh-cua-bundle` 并启用;此前选中过内置副本的 profile 也需完成这次安装。按提示重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 +1. 升级 Computer Use 包后重启 DSH。在 Computer Use 插件页按 macOS 提示,给 `DSH Computer Use.app` 授予辅助功能和屏幕录制权限。 2. 删除过时的 `helperPath` 覆盖配置或 `DSH_CUA_HELPER` 值,使用自动准备。使用自定义构建时,将其指向完整应用包内的 `DSH Computer Use.app/Contents/MacOS/dsh-cua-native` 可执行文件。 3. 恢复原生应用任务前,确认权限页显示 `DSH Computer Use.app`,且两项权限均已允许。具体目标应用仍需另行许可。 4. 若终端或 Desktop 启动器的这两项权限只用于 Computer Use,可在系统设置中撤销这些旧授权。给 helper 授权不会撤销其他应用已有的权限;模型运行的程序仍遵循启动器的系统权限和 DSH 沙箱策略。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.i18n.yaml new file mode 100644 index 0000000000..dadc616c76 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md +/: + en: f09a5fde2cedb48c + zh: beec903a10ed62ee +/agent-waits-require-inbox-input: + en: 96c9f5fc6514557a + zh: aac52f37d543ed9b +/agent-waits-require-inbox-input/change: + en: 568583cb15f85337 + zh: 6c7e3028f23d1f3e +/agent-waits-require-inbox-input/migration: + en: 156a027d11ad3769 + zh: 301029cecd3ae640 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md new file mode 100644 index 0000000000..9252add26b --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Task-board changes no longer end wait_agent calls; send a message to request further work." +--- +# Agent waits require inbox input + +English | [中文](guide.zh.md) + +## Change + +`wait_agent` waits only for the calling Agent's next-step inbox. Task creation, assignment, completion, and other task updates no longer end an active wait. They do not add inbox messages or start idle Agents. Queued next-turn input and other Agents' status changes also leave the wait pending. + +Existing next-step input returns immediately; new messages, completion notifications, and user steering end the wait without consuming that input. Timeout and cancellation behavior remain unchanged. + +## Migration + +1. After changing a task, use `send_message` when its owner or another member needs to act on the update. Do not rely on a task mutation to release `wait_agent`. +2. Read current task state before acting; a task update is not a message or an instruction to begin a new turn. +3. Verify that a task update leaves an empty-inbox wait pending, then send a message and confirm that the wait returns with `timedOut: false`. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.zh.md new file mode 100644 index 0000000000..27bd015147 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/inbox-only-agent-wait/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "任务板变更不再结束 wait_agent 调用;需要继续工作时发送消息。" +--- +# Agent 等待需要收件箱输入 + +[English](guide.md) | 中文 + +## 变更 + +`wait_agent` 仅等待调用 Agent 的下一步收件箱。任务创建、分配、完成和其他任务更新不再结束正在进行的等待,也不添加收件箱消息或启动空闲 Agent。排队的下一轮输入和其他 Agent 的状态变化同样不会结束等待。 + +已有下一步输入时立即返回;新消息、完成通知和用户引导会结束等待,但不消费输入。超时和取消行为保持不变。 + +## 迁移 + +1. 修改任务后,需要任务所有者或其他成员处理更新时使用 `send_message`。不要依赖任务修改来结束 `wait_agent`。 +2. 行动前读取当前任务状态;任务更新不是消息,也不是开始新轮次的指令。 +3. 验证任务更新不会结束空收件箱上的等待,然后发送消息,确认等待返回 `timedOut: false`。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.i18n.yaml new file mode 100644 index 0000000000..2972e9a489 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md +/: + en: 10f7c9b47f2b39f2 + zh: 2c93aa4e6bef0f33 +/subagent-runtime-ownership: + en: 20c105e3d68b46ee + zh: 685c1ad4eef7eefb +/subagent-runtime-ownership/change: + en: bacb015157610964 + zh: 635055eab24394ac +/subagent-runtime-ownership/migration: + en: 3fb0ce93ad94074f + zh: 49e2f520191d6c53 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md new file mode 100644 index 0000000000..2cc07287e3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: Agent factory ownership parameters and registry tree queries are removed; subagent Activations own runtime tree membership. +--- + +# Subagent runtime ownership + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-agent` removes the create/resume runtime-owner parameter (`parentAgent` or `ownerAgent`), `AgentRegistry.isOwnedBy()`, `AgentRegistry.roots()`, and the owner argument of `enter()`. Direct factory creation no longer enrolls an Agent in a managed subagent tree. Custom factories no longer implement runtime tree ownership. + +The subagent service resolves runtime membership from its existing Activations through `rootOf(agent)`. The Session format and Cordis context ownership of handles are unchanged. + +## Migration + +1. Create delegated work through `ctx.subagents.startActivation()` with a registered provider. Remove ownership parameters from direct `ctx.agents.create()` and `resume()` calls; these operations create independent runtime instances. +2. Use `ctx.agents.get(id)` and `list()` for live lookup. To select ordinary live Agents, filter `list()` by `agent.session.header.origin !== 'subagent'`; historical ordinary forks can still have `parentSession`. +3. Query the live tree root with `ctx.subagents.rootOf(agent)` and compare Agent objects, not only ids. Ordinary live Agents resolve to themselves; unmanaged delegated or stale instances throw `SubagentError` with code `UNAUTHORIZED`. Read `session.header.parentSession` for the direct creator; use durable subagent catalogs when traversing offline creation ancestors. +4. Update custom factory implementations to call `ctx.agents.enter(agent)` after setup. Retain caller-context disposal, initialization rollback, and exact-instance registration checks. Runtime tree membership is available after managed activation publication; `agent/created` runs before that point. Verify stale instances cannot reuse a disposed instance's ownership. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.zh.md new file mode 100644 index 0000000000..ee212f5c96 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/subagent-runtime-ownership/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: Agent 工厂归属参数和注册表树查询已删除;subagent Activation 拥有运行时树成员关系。 +--- + +# Subagent 运行时归属 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-agent` 删除 create/resume 的运行时归属参数(`parentAgent` 或 `ownerAgent`)、`AgentRegistry.isOwnedBy()`、`AgentRegistry.roots()` 以及 `enter()` 的 owner 参数。直接通过工厂创建 Agent 不再将其加入托管 subagent 树。自定义工厂不再实现运行时树归属。 + +subagent 服务通过 `rootOf(agent)` 从已有 Activation 解析运行时成员关系。Session 格式和 Cordis 上下文对句柄的所有权不变。 + +## 迁移 + +1. 通过 `ctx.subagents.startActivation()` 和已注册提供方创建委派工作。删除直接调用 `ctx.agents.create()` 与 `resume()` 时的归属参数;这些操作创建独立的运行时实例。 +2. 通过 `ctx.agents.get(id)` 与 `list()` 查询存活实例。筛选普通存活 Agent 时,用 `agent.session.header.origin !== 'subagent'` 过滤 `list()`;历史普通 fork 仍可能包含 `parentSession`。 +3. 通过 `ctx.subagents.rootOf(agent)` 查询存活树根,并比较 Agent 对象,而非仅比较 id。普通存活 Agent 返回自身;非托管的委派实例或失效实例抛出代码为 `UNAUTHORIZED` 的 `SubagentError`。直接创建者读取 `session.header.parentSession`;遍历离线创建祖先时使用持久化 subagent 目录。 +4. 更新自定义工厂,在 setup 后调用 `ctx.agents.enter(agent)`。保留调用方上下文释放、初始化回滚和确切实例注册检查。运行时树成员关系在托管 activation 发布后可用;`agent/created` 早于该时点。验证失效实例不能复用已释放实例的归属。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.i18n.yaml new file mode 100644 index 0000000000..8916474764 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md +/: + en: 1c39dada6c6162a7 + zh: ab2f5e2824bfbf0c +/select-team-mode-from-the-conversation-menu: + en: d570d0f970f6a77a + zh: 7a722461f27bc620 +/select-team-mode-from-the-conversation-menu/change: + en: 37b569a98d41587d + zh: ce9c8ee4c35ac634 +/select-team-mode-from-the-conversation-menu/migration: + en: d548bbca6276ed74 + zh: 2468a89bd342b3ff diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md new file mode 100644 index 0000000000..0d54d68096 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "The Team mode slash command is removed; use the root conversation menu." +--- + +# Select Team mode from the conversation menu + +English | [中文](guide.zh.md) + +## Change + +`/team on` and `/team off` are no longer registered. The root conversation’s **Use Agent Teams** switch calls `remote.agentTeams.setEnabled` directly. Saved selections, task retention, and root-only authorization are unchanged. + +## Migration + +1. Enable or disable **Use Agent Teams** in the root conversation’s model and reasoning menu. +2. Plugins that execute the command should call `ctx.agentTeams.setEnabled(rootAgent, enabled)` instead; browser clients call `remote.agentTeams.setEnabled(rootSessionId, enabled)`. +3. Confirm the switch reflects the saved selection after reloading the Session. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.zh.md new file mode 100644 index 0000000000..c8e847e91f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-mode-menu/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Team 模式斜杠命令已移除,请使用根会话菜单。" +--- + +# 从会话菜单选择 Team 模式 + +[English](guide.md) | 中文 + +## 变更 + +`/team on` 和 `/team off` 不再注册。根会话的**使用智能体团队**开关直接调用 `remote.agentTeams.setEnabled`。保存的选择、任务保留以及仅允许根会话修改的授权规则不变。 + +## 迁移 + +1. 在根会话的模型与推理等级菜单中打开或关闭**使用智能体团队**。 +2. 执行该命令的插件改为调用 `ctx.agentTeams.setEnabled(rootAgent, enabled)`;浏览器客户端调用 `remote.agentTeams.setEnabled(rootSessionId, enabled)`。 +3. 重新加载 Session,确认开关反映已保存的选择。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.i18n.yaml new file mode 100644 index 0000000000..b709e46f55 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md +/: + en: 185304d86b229610 + zh: b98e40567653a55f +/team-projection-retains-tasks-and-mode: + en: c20510084cf9d412 + zh: ee26f2365ab5d216 +/team-projection-retains-tasks-and-mode/change: + en: 5b92baca861b7281 + zh: 57b4162fb425163c +/team-projection-retains-tasks-and-mode/migration: + en: 6c30148dea17af5e + zh: 81b7fa72cc118252 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md new file mode 100644 index 0000000000..fe0258e51e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Team projection stops restoring historical teammates and mailbox state while retaining tasks and saved mode." +--- + +# Team projection retains tasks and mode + +English | [中文](guide.zh.md) + +## Change + +The `agentTeam` projection no longer restores `members`, `messages`, or `delivered` from historical `team/member` and `team/message/*` events. It ignores their payloads, so historical roster or mailbox validation failures no longer block task reads or writes. Historical teammates are not restored and queued mail is not delivered. + +Task revisions, task owners, deleted-task records, and the saved Team mode remain supported. Ordinary subagent discovery and messaging are unchanged. The Session format and committed log files do not change; projection checkpoint version 7 rebuilds older cached state from the log. + +## Migration + +1. No data conversion or configuration change is required. Open the Session normally to rebuild incompatible projection checkpoints. +2. Use `list_agents` and `send_message` for current teammates. Read historical roster or mailbox events from the original Session log if you need an archive; the Team projection no longer supplies these collections. +3. Confirm that the task board and saved Team mode are restored. Historical roster and mailbox records do not grant task-assignment authority. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.zh.md new file mode 100644 index 0000000000..b6b131599e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/team-projection/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Team 投影停止恢复历史队友与邮箱状态,同时保留任务和保存的模式。" +--- + +# Team 投影保留任务与模式 + +[English](guide.md) | 中文 + +## 变更 + +`agentTeam` 投影不再从历史 `team/member` 和 `team/message/*` 事件恢复 `members`、`messages` 或 `delivered`。投影忽略这些载荷,因此历史名册或邮箱校验失败不再阻止任务读写。不恢复历史队友,也不投递排队邮件。 + +任务修订、任务所有者、已删除任务记录和保存的 Team 模式仍受支持。普通子代理发现与通信保持不变。Session 格式和已提交日志文件不变;投影检查点版本 7 从日志重建旧缓存状态。 + +## 迁移 + +1. 无需转换数据或修改配置。正常打开 Session 即可重建不兼容的投影检查点。 +2. 使用 `list_agents` 和 `send_message` 与当前队友协作。需要归档信息时,从原始 Session 日志读取历史名册或邮箱事件;Team 投影不再提供这些集合。 +3. 确认任务板和保存的 Team 模式已恢复。历史名册与邮箱记录不授予任务分配权限。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml new file mode 100644 index 0000000000..3acec7569f --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md +/: + en: 2f1907963c732ea8 + zh: e25494c07150f6b9 +/saved-api-keys-in-provider-editors: + en: 63376a595a0e6dae + zh: 2709df13410087e2 +/saved-api-keys-in-provider-editors/change: + en: 463718dbe83f900d + zh: d2e55dfebf8a4b9e +/saved-api-keys-in-provider-editors/migration: + en: 19c94ae9626773b1 + zh: 1098744f47162c43 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md new file mode 100644 index 0000000000..8e721ad800 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Saved API keys appear in masked editor fields, and clearing a saved key removes its override." +--- +# Saved API keys in provider editors + +English | [中文](guide.zh.md) + +## Change + +Provider editors show stored API keys as masked values instead of empty fields. Applying an empty field removes a previously loaded key and restores environment authentication. Cancel preserves the stored key. Environment values are never loaded into the editor. + +Credential providers implement `readStored(ref): Promise` to return only the managed value, without environment fallbacks. The owner-only `credentials.readStored` Remote method supplies this value to an opened editor; general metadata reads remain redacted. + +## Migration + +1. Keep the loaded value to retain a saved key, replace it to rotate the key, or clear it and apply to use environment authentication. +2. Implement `readStored` in custom `CredentialProvider` subclasses; return `undefined` when no non-empty value is stored. +3. Reopen the DeepSeek editor to confirm the saved key appears masked. After clearing and applying, confirm the field is empty and offers environment authentication. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md new file mode 100644 index 0000000000..75efbcc4d7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/credential-editor/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "已保存的 API 密钥显示为编辑器中的掩码值,清空已保存密钥会删除其覆盖。" +--- +# 提供商编辑器中的已保存 API 密钥 + +[English](guide.md) | 中文 + +## 变更 + +提供商编辑器以掩码值显示已保存的 API 密钥,不再显示空字段。应用空字段会删除之前加载的密钥并恢复环境认证。「取消」保留已保存密钥。环境变量值从不加载到编辑器。 + +凭据提供方实现 `readStored(ref): Promise`,仅返回托管值,不使用环境后备。仅允许所有者调用的 `credentials.readStored` Remote 方法向已打开的编辑器提供此值;普通元数据读取仍保持脱敏。 + +## 迁移 + +1. 保留加载的值即可保留已保存密钥,替换它可轮换密钥,清空并应用则使用环境认证。 +2. 为自定义 `CredentialProvider` 子类实现 `readStored`;未存储非空值时返回 `undefined`。 +3. 重新打开 DeepSeek 编辑器,确认已保存密钥显示为掩码。清空并应用后,确认字段为空且提示使用环境认证。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.i18n.yaml new file mode 100644 index 0000000000..1e9d8c17c2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md +/: + en: 2f745e146345238c + zh: 9b41a7625ae45170 +/source-built-computer-use-helper-identity: + en: 72a8ea364f7427fa + zh: 9e7ed2e00425bddb +/source-built-computer-use-helper-identity/change: + en: c1aa84364896b994 + zh: a4559c563d995b2b +/source-built-computer-use-helper-identity/migration: + en: a86e330ab60ef800 + zh: ce32e1959ca707fc diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md new file mode 100644 index 0000000000..92d0339b4e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "Source-built Computer Use helpers use a separate development bundle identifier and need one new macOS permission grant." +--- + +# Source-built Computer Use helper identity + +English | [中文](guide.zh.md) + +## Change + +Source checkouts previously built `DSH Computer Use.app` with the release bundle identifier `com.deepseek.harness.computer-use` and an ad-hoc signature. They now build `DSH Computer Use Dev.app` with bundle identifier `com.deepseek.harness.computer-use.dev`. Accessibility and Screen Recording grants for the old entry do not apply to it. Desktop and npm installations are unchanged. Source builds still default to ad-hoc signing; developers generate and maintain fixed identities outside DSH home and select them through configuration. + +## Migration + +1. Keep the default `sourceSigning: ad-hoc` for temporary signatures. To reduce repeated grants after rebuilds and across worktrees, follow the [developer signing skill](../../../../.agents/skills/dsh-cua-macos-signing/SKILL.md) to generate or reuse a certificate, then write `sourceSigning: { identity, keychain }` into the DSH home’s `cordis.patch.yml`; `keychain` must be an absolute path. +2. Start DSH from the source checkout, call Computer Use, and grant Accessibility and Screen Recording to `DSH Computer Use Dev` in System Settings. +3. Confirm that `getCapabilities` reports both permissions as `true`. With a fixed signature, check again after changing and rebuilding Swift code and from another worktree. Remove an old `DSH Computer Use` entry only when no installed DSH uses it. +4. After the keychain locks, rerun setup as described in the skill to unlock and reuse the identity. To stop using it, select `ad-hoc` or remove the configuration, then remove the identity through the skill; deleting DSH home does not delete the developer keychain. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.zh.md new file mode 100644 index 0000000000..64552b7524 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/cua-source-dev-helper/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "源码构建的 Computer Use helper 使用独立的开发版 Bundle ID,需要重新授予一次 macOS 权限。" +--- + +# 源码构建的 Computer Use helper 身份 + +[English](guide.md) | 中文 + +## 变更 + +源码检出原先以临时签名构建 `DSH Computer Use.app`,使用正式版 Bundle ID `com.deepseek.harness.computer-use`。现在构建 `DSH Computer Use Dev.app`,使用 `com.deepseek.harness.computer-use.dev`。旧条目的辅助功能和屏幕录制授权不能沿用。Desktop 和 npm 安装版不变。源码构建默认仍为临时签名;固定身份由开发者在 DSH home 之外生成和维护,再通过配置指定。 + +## 迁移 + +1. 保持默认 `sourceSigning: ad-hoc` 即可使用临时签名。需要减少重新编译和跨 worktree 的授权次数时,按[开发者签名 skill](../../../../.agents/skills/dsh-cua-macos-signing/SKILL.md)生成或复用证书,并将 `sourceSigning: { identity, keychain }` 写入 DSH home 的 `cordis.patch.yml`;`keychain` 必须为绝对路径。 +2. 从源码检出启动 DSH,调用 Computer Use,在系统设置中给 `DSH Computer Use Dev` 授予辅助功能和屏幕录制权限。 +3. 确认 `getCapabilities` 的两项权限均为 `true`。使用固定签名时,在修改 Swift 源码重建和另一个 worktree 中分别复查。只有确认没有安装版 DSH 使用旧条目时,才移除旧 `DSH Computer Use` 条目。 +4. 钥匙串锁定后,按 skill 重新运行 setup 解锁并复用现有身份。停止使用时改回 `ad-hoc` 或移除该配置,再按 skill 删除身份;删除 DSH home 不会删除开发者钥匙串。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.i18n.yaml new file mode 100644 index 0000000000..24621ccc04 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md +/: + en: 74a51bb421eb125c + zh: 2fccbfa5bcafb2af +/subagent-reasoning-overrides-require-an-explicit-route: + en: f6bc10b254fe4420 + zh: 9ac48f0264d1bd69 +/subagent-reasoning-overrides-require-an-explicit-route/change: + en: e31a906fc29dd5a6 + zh: 2fa39b8bcbdfdb38 +/subagent-reasoning-overrides-require-an-explicit-route/migration: + en: 9ebfdab054389c89 + zh: 7c50e28db91781e4 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md new file mode 100644 index 0000000000..70bd516d85 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Subagent reasoning overrides require an explicit provider and model, and explicit routes use model-default reasoning when effort is omitted." +--- + +# Subagent reasoning overrides require an explicit route + +English | [中文](guide.zh.md) + +## Change + +Delegation tools with model selection enabled reject `reasoning_effort` unless the same call supplies both `provider` and `model`. Previously, an effort-only call could use a configured or inherited route. The rejection occurs before child creation. + +An explicit provider/model pair without `reasoning_effort` uses the selected model's default effort, including when the pair matches the parent or configured child route. Previously, the unchanged route could retain a configured or parent effort. Omitting all three tool fields preserves the configured child defaults and existing fallback behavior. + +## Migration + +1. Update prompts, skills, and tool-call producers that send only `reasoning_effort`: include the authorized `provider` and `model`, or omit all three fields to use the default child selection. Query `list_subagent_models` for authorized routes and supported effort ids. +2. To preserve a specific effort while selecting a route, pass it explicitly as `reasoning_effort`. +3. Confirm an effort-only call returns a missing-route error without starting a child. For a complete route, check the child's logged `request/header` configuration: an omitted effort follows that model's default rather than the parent's selected effort. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.zh.md new file mode 100644 index 0000000000..4ab85b612a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/subagent-explicit-reasoning/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "subagent 推理强度覆盖要求显式指定提供方和模型,显式路由省略强度时使用模型默认值。" +--- + +# subagent 推理强度覆盖要求显式路由 + +[English](guide.md) | 中文 + +## 变更 + +启用模型选择的委派工具要求同一次调用同时提供 `provider` 和 `model`,否则拒绝 `reasoning_effort`。此前,仅指定强度的调用可以使用配置或继承的路由。拒绝发生在创建子级之前。 + +显式提供提供方/模型组合但省略 `reasoning_effort` 时,使用所选模型的默认强度,即使该组合与父级或配置的子级路由相同。此前,路由不变时可能保留配置或父级强度。省略全部三个工具字段时,仍保留配置的子级默认值与现有回退行为。 + +## 迁移 + +1. 更新仅发送 `reasoning_effort` 的提示、skill 和工具调用生成方:补齐已授权的 `provider` 和 `model`,或省略全部三个字段以使用默认子级选择。通过 `list_subagent_models` 查询已授权路由与支持的强度 ID。 +2. 选择路由时若要保留某个强度,显式传入 `reasoning_effort`。 +3. 确认仅指定强度的调用返回缺少路由的错误,且没有启动子级。对于完整路由,检查子级日志中的 `request/header` 配置:省略强度时应采用该模型的默认值,而不是父级选择的强度。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index b04aada43b..62801fa60d 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -6,11 +6,11 @@ en: b1631dcf25e805a1 zh: 8ded02e2ae87f00b /configure-models/configure-deepseek: - en: 44852da64a6a39ec - zh: 4c280e428f0fec08 + en: 5cf396eb7f22b0f6 + zh: d70688c6cd826f3a /configure-models/add-a-third-party-provider: - en: f6fb44a85dc5e4dd - zh: 2b86ea4492c32ed7 + en: c8fd933006c3830c + zh: 386f8f7d302e98c3 /configure-models/add-a-custom-model-api: en: 17867305a3e1b52a zh: 876fedf1f6f563e4 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index fe831bcaf9..1ab90f440e 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -10,7 +10,7 @@ Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter ![The Models page: the DeepSeek card, with Add model provider below it](providers-models-page.png) -Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. +Opening a provider editor loads its saved key into a masked input. Environment values are never loaded into this input. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. Credential metadata remains redacted. With a key saved and the default endpoint, DeepSeek's models follow the list the DeepSeek API publishes. The list loads when the models are first needed, refreshes in the background about every ten minutes while they are in use, and falls back to the built-in models when it cannot load. Editing **Models** under **Customized settings** keeps your own list instead and stops following the published one; **Restore defaults** follows it again and reloads it. While the published list is in use, the card shows the built-in models, so a newly published model appears only in the model picker. The [API-key provider reference](../../../packages/llm/llm-deepseek-api-key/README.md) owns the listing, fallback, and gating rules. @@ -18,11 +18,11 @@ With a key saved and the default endpoint, DeepSeek's models follow the list the Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. -The authentication **?** explains saved-credential priority and environment fallbacks. Leave the key blank to use a saved credential or try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. +The authentication **?** explains saved-credential priority and environment fallbacks. With no saved key, leave the input blank to try the listed environment variables. A green hint identifies a detected environment variable. A yellow hint means no credential variable was detected; enter an API key or configure the listed variable. An unconfirmed hint means the status read failed or a saved key still covers the environment lookup. Clearing a saved key shows an unconfirmed hint until you save its removal and the page refreshes the environment check. Detection does not validate the credential or test the connection. Provider-managed credential files, including AWS profiles and Google application-default credentials, are outside this check. Providers without native key authentication, such as `openai-codex`, show no key field unless the profile explicitly names a credential reference. -A saved credential uses the placeholder **Leave blank to use saved credentials**, with no status text below the input. Environment fallback uses **Leave blank to use env as auth** and one detection hint while the input is empty. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. +A saved key appears masked, with no status text below the input. An empty input uses **Leave blank to use env as auth** and one detection hint. A nonempty input shows no authentication status text. Address configuration variables appear under **Base URL** and do not count as credentials. -A nonempty key input has an eye button. Select **Show secret** to check what you typed, and **Hide secret** to mask it again. Changing the input masks it again. Stored credentials remain write-only. +A nonempty key input has an eye button. Select **Show secret** to check the loaded or newly entered key, and **Hide secret** to mask it again. Changing the input masks it again. Saving an unchanged key preserves its exact stored value. Clearing it and saving removes the saved key. Cancelling preserves the saved key. ## Add a custom model API diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index bb518c42b9..ddb44f1814 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -10,7 +10,7 @@ ![模型页:DeepSeek 卡片,及其下方的添加模型提供商入口](providers-models-page.zh.png) -密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 +打开提供商编辑器时,已保存密钥会载入遮罩输入框。环境变量的值不会载入此输入框。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。凭据元数据仍保持脱敏。 保存了密钥且使用默认端点时,DeepSeek 的模型跟随 DeepSeek API 发布的列表。第一次需要模型时加载列表,使用期间约每十分钟在后台刷新一次,无法加载时退回内置模型。在**自定义设置**里编辑**模型目录**会改用你自己的列表,不再跟随发布的列表;点**恢复默认模型**会重新跟随并重新加载。使用发布的列表时,卡片上显示的是内置模型,因此新发布的模型只出现在模型选择器中。列表、兜底和门控规则见 [API key 提供方参考](../../../packages/llm/llm-deepseek-api-key/README.zh.md)。 @@ -18,11 +18,11 @@ 选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -认证 **?** 说明已保存凭据的优先级和环境后备。密钥留空时,使用已保存凭据,或尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 +认证 **?** 说明已保存凭据的优先级和环境后备。没有已保存密钥时,输入留空会尝试读取列出的环境变量。绿色提示标明已检测到环境变量。黄色提示表示未检测到凭据变量;请输入 API 密钥或配置列出的变量。状态未确认表示读取失败,或已保存密钥仍覆盖环境读取。清空已保存密钥后,提示会保持未确认,直到保存删除操作并刷新环境检测。检测不验证凭据,也不测试连接。AWS profile 和 Google 应用默认凭据等提供商管理的凭据文件不在检查范围内。没有原生密钥认证的提供商(例如 `openai-codex`)不显示密钥字段,除非 profile 显式指定了凭据引用。 -已保存凭据使用**留空使用已保存凭据**占位文字,输入框下方不显示状态文字。环境后备使用**留空使用环境认证**占位文字,输入留空时显示一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 +已保存密钥以遮罩显示,输入框下方不显示状态文字。输入留空时,显示**留空使用环境认证**占位文字和一条检测提示。输入非空时,不显示认证状态文字。地址配置变量显示在**基础 URL**下方,不计作凭据。 -密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查当前输入,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。已保存凭据仍保持只写。 +密钥输入非空时,输入框内显示眼睛按钮。选择**显示密钥**检查已载入或新输入的密钥,选择**隐藏密钥**重新遮住输入。修改输入后会重新遮住密钥。保存未修改的密钥会保留原值。清空并保存会删除已保存密钥。取消编辑会保留已保存密钥。 ## 添加自定义模型 API diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 92c89f5ff2..d96e805d23 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -15,8 +15,8 @@ en: ffc55792d6e922e1 zh: 83bd759638857e2d /packages/package-groups: - en: c397fc0c1885058a - zh: c720d0e91dd1d15e + en: 60a6056460b8d4c1 + zh: fce319504ae9b6ef /packages/release-expectations: en: 236081c1659b25ab zh: 809110adf003802e diff --git a/packages/README.md b/packages/README.md index ed7c55ec5a..85a63643d0 100644 --- a/packages/README.md +++ b/packages/README.md @@ -52,6 +52,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`compaction/`](compaction/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | | [`context/`](context/README.md) | Model-visible request context: workspace instructions, time context, references | | [`subagent/`](subagent/README.md) | Subagent capability family: provider-registry contract and model-facing delegation tools | +| [`team/`](team/README.md) | Team coordination: shared task boards and model-facing task tools | | [`jobs/`](jobs/README.md) | Generic background-job runtime and model-facing job control tools | | [`experimental/`](experimental/README.md) | Capabilities under evaluation; [status and support policy](experimental/README.md#status) | | [`workflow/`](workflow/README.md) | Workflow seam, PTC process engine, and the model-facing `workflow` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index ae3a18bbc1..f293666d2f 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -52,6 +52,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`compaction/`](compaction/README.zh.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | | [`context/`](context/README.zh.md) | 模型可见请求上下文:workspace 指令、时间上下文、引用 | | [`subagent/`](subagent/README.zh.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | +| [`team/`](team/README.zh.md) | Team 协调:共享任务板与面向模型的任务工具 | | [`jobs/`](jobs/README.zh.md) | 通用后台任务运行时和面向模型的作业控制工具 | | [`experimental/`](experimental/README.zh.md) | 仍在评估中的能力;[状态与支持策略](experimental/README.zh.md#status) | | [`workflow/`](workflow/README.zh.md) | 工作流 seam、PTC 进程引擎、面向模型的 `workflow` 工具 | diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index 2af289b3a2..e1abf0b2f0 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -24,8 +24,8 @@ import { assistantUpdates, toolCallUpdate, toolResultUpdate } from './updates.ts interface SubagentLifecycle { /** Dispose subagent descendants below exact host-owned parents child-first. */ drainDescendants(parents: readonly Agent[]): Promise - /** Observe currently managed descendants without closing future admission. */ - waitForChildren(parent: Agent): Promise + /** Join progressing work in the root's subagent tree without closing future admission. */ + waitForTreeWork(root: Agent): Promise } /** Inputs shared by fresh and resumed ACP session construction. */ @@ -489,7 +489,7 @@ export class AcpSession { await Promise.race([this.agent.whenIdle(), cancelled]) if (isCancelled()) break const idleSeq = this.agent.session.seq - const children = await Promise.race([subagents?.waitForChildren(this.agent), cancelled]) + const children = await Promise.race([subagents?.waitForTreeWork(this.agent), cancelled]) if (isCancelled()) break if (children || !idleAt(idleSeq)) continue await this.outputTail diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index e85c2553d9..39eb9f784e 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -50,7 +50,7 @@ async function installDelegationTool(ctx: BridgeHarness['ctx']): Promise<() => b if (exec.agent === undefined) throw new Error('delegation fixture requires an Agent') const child = await ctx.subagents.startActivation({ provider: 'local-test', label: 'later child', delivery: 'caller', signal: exec.signal, - request: { parent: exec.agent, prompt: [{ type: 'text', text: 'later child task' }] }, + parent: exec.agent, prompt: [{ type: 'text', text: 'later child task' }], }) const result = await child.result delegated = result.stopReason === 'completed' @@ -215,7 +215,7 @@ describe('ACP prompt lifecycle', () => { const appendSpy = vi.spyOn(parent.session, 'append') const failures: number[] = [] harness.ctx.on('agent/error', ({ turn }) => { failures.push(turn) }) - const waitForChildren = vi.fn().mockImplementationOnce(() => { + const waitForTreeWork = vi.fn().mockImplementationOnce(() => { appendSpy.mockImplementation(((type: string, ...rest: never[]) => { if (type === failedEvent) throw new Error(`summary ${failedEvent} unavailable`) return (append as (...args: never[]) => unknown)(type as never, ...rest) @@ -223,7 +223,7 @@ describe('ACP prompt lifecycle', () => { parent.followup(createUserMessage({ content: [{ type: 'text', text: 'child result' }], source: { kind: 'test' } })) return Promise.resolve(true) }).mockResolvedValue(false) - harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants: vi.fn() } as never) try { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] })) .rejects.toThrow(`summary ${failedEvent} unavailable`) @@ -594,10 +594,10 @@ describe('ACP prompt lifecycle', () => { const parent = harness.ctx.agents.get(SessionId(sessionId))! const waiting = Promise.withResolvers() const children = Promise.withResolvers() - const waitForChildren = vi.fn() + const waitForTreeWork = vi.fn() .mockImplementationOnce(() => { waiting.resolve(undefined); return children.promise }) .mockResolvedValue(false) - harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants: vi.fn() } as never) let settled = false const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }) const observed = prompt.then(() => { settled = true }, () => { settled = true }) @@ -619,11 +619,11 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const waiting = Promise.withResolvers() const children = Promise.withResolvers() - const waitForChildren = vi.fn() + const waitForTreeWork = vi.fn() .mockImplementationOnce(() => { waiting.resolve(undefined); return children.promise }) .mockResolvedValue(false) const drainDescendants = vi.fn() - harness.ctx.provide('subagents', { waitForChildren, drainDescendants } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants } as never) const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }) await waiting.promise await harness.client.cancel({ sessionId }) @@ -647,7 +647,7 @@ describe('ACP prompt lifecycle', () => { const parent = ctx.agents.get(SessionId(sessionId))! const waiting = Promise.withResolvers() const children = Promise.withResolvers() - vi.spyOn(ctx.subagents, 'waitForChildren').mockImplementationOnce(() => { + vi.spyOn(ctx.subagents, 'waitForTreeWork').mockImplementationOnce(() => { waiting.resolve(undefined) return children.promise }) @@ -873,11 +873,11 @@ describe('ACP prompt lifecycle', () => { await delivery.promise if (fail) throw new Error('summary image unavailable') } - const waitForChildren = vi.fn().mockImplementationOnce(() => { + const waitForTreeWork = vi.fn().mockImplementationOnce(() => { parent.followup(createUserMessage({ content: [{ type: 'text', text: 'child result' }], source: { kind: 'test' } })) return Promise.resolve(true) }).mockResolvedValue(false) - harness.ctx.provide('subagents', { waitForChildren, drainDescendants: vi.fn() } as never) + harness.ctx.provide('subagents', { waitForTreeWork, drainDescendants: vi.fn() } as never) let settled = false const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'delegate' }] }) const observed = prompt.then(() => { settled = true }, () => { settled = true }) @@ -910,7 +910,7 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const parent = ctx.agents.get(SessionId(sessionId))! const noChildren = Promise.withResolvers() - vi.spyOn(ctx.subagents, 'waitForChildren').mockImplementationOnce(() => { + vi.spyOn(ctx.subagents, 'waitForTreeWork').mockImplementationOnce(() => { noChildren.resolve(undefined) return Promise.resolve(false) }) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index f3b9eab6fc..64b8496be6 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,8 +15,8 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: a80d44e0683768a5 - zh: 379833ac180e5418 + en: 6e3448339c646a7e + zh: 5dc00ff4cceaa88c /deepseek-ai-dsh-api-remotes/forwarded-host-events: en: 31bb9a2f50094b84 zh: 226c055739d5def7 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 8d49cb49a5..a05773a4af 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,7 +29,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package selects and mounts that generated Session contribution without a direct runtime dependency on `@deepseek-ai/dsh-session`; activation policy stays with Session Controller. -The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 4f9772403d..bc616b9588 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包选择并挂载生成的会话 contribution,无需直接依赖 `@deepseek-ai/dsh-session` 的运行时;激活策略由会话控制器负责。 -Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8735bc4339..1fe16a4a87 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -99,6 +99,7 @@ "@deepseek-ai/dsh-api-account-controller": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 8a4b7dea8a..86112b7cc2 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -6,6 +6,8 @@ export type {} from '@deepseek-ai/dsh-client-product-analytics/remote' import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote' import userQuestionsRemote from '@deepseek-ai/dsh-user-questions/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' +import agentTeamsRemote from '@deepseek-ai/dsh-agent-team/remote' +export type {} from '@deepseek-ai/dsh-agent-team/remote' import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote' @@ -189,7 +191,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - productAnalyticsRemote, agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, + productAnalyticsRemote, agentPresetsRemote, commandsRemote, agentTeamsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote, pluginInventoryRemote, pluginManagerRemote, pluginMarketRemote, pluginRegistryProbeRemote, toolResultsRemote, messageFeedbackRemote, sessionFeedbackRemote, diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 70181b08c5..49b3180c43 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -11,6 +11,7 @@ "src/types.ts" ], "references": [ + { "path": "../../team/agent-team" }, { "path": "../../client/product-analytics/tsconfig.client.json" }, { "path": "../../../vendor/cordis" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 8bea04d9db..38ebd48e01 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,11 +15,11 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 22136176884a239b - zh: b8eeea5908b9a6fd + en: 300f02be359a3170 + zh: ac7fb4abbe78eddf /session-controller/client-references: - en: 41bdda1ac32df558 - zh: aa5d0e325924066b + en: e8979a803ec3e194 + zh: c567efcd971728ba /session-controller/session-media-references: en: 954923654eebe2dc zh: 6bd9a7d2ea965aed diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 3a9dc2cd6b..960c9bde1f 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. For a cold Session, list reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. An attached Session is summarized from its in-memory log. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. For a cold Session, list reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. An attached Session is summarized from its in-memory log. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and the subagent ownership fence determined by `origin: 'subagent'` to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/events`, the deprecated `session/page`, `session/observeEvent`, and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections`, `session/observeProjections`, `session/state`, `session/observeState`, and `session/wait` read the Session resource from `request.sessionId`, so each call and subscription names only the Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. @@ -76,14 +76,14 @@ Queue edits replace pending content with non-empty text only. `session.attachment` receives `{ sessionId, attachment: ImageAttachmentRef }` and passes the complete reference to `AttachmentStore.readImage()`, without reading or traversing Session history. The Client supplies the message's reference through `session.readAttachment(ref)`. Connection authentication and Gateway permission checks still apply; `sessionId` selects the Session resource for the endpoint's permission check. After authorization, the handler does not check whether that Session exists and does not return `session/not-found` merely because it is absent. An authorized caller may read any image in the same store, including an image referenced only by another Session or by no Session. The response preserves the supplied reference after verifying its ID, media type, byte length, width, and height against the stored bytes. Optional `name` and `originalDimensions` pass through as caller-supplied display information and are not verified against the file. Missing or invalid objects return a terminal attachment failure. The Client's historical-image cache retains one result and browser URL per Session and attachment ID. -The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read from the Session header's lineage fields, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself. +The controller composes `ArchivedSessionGate` through `ctx.plugin`: it loads once the Agent registry, Session store, and Workspace registry exist and unwinds with the controller. Its `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one — read through available Session headers, never a fork — so a late waking delivery ends its turn as `blocked` without a model request; unarchiving lifts the gate for the whole lineage. The Subagent runtime separately checks retained creation ancestors, covering managed descendants whose intermediate creators are offline. The work an archived Session still runs is reported and stopped by its owners through the Workspace registry's archive admission ([seam](../../workspace/workspace/README.md)): the running turn by the [Agent registry](../../core/agent/README.md), owned jobs by the [job registry seam](../../jobs/jobs/README.md), subagent descendants by the [Subagent](../../subagent/subagent/README.md) runtime, reminders by the [Schedule](../../schedule/schedule/README.md) plugin; this controller reports nothing itself. ## Client references `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent consistency validation; access is checked separately on the child Session. Successful descriptor restoration determines the displayed mode; failures remain local to the child. Control requests carry only the target id, and the Host checks continuation availability. Client snapshots retain only the subagent address; runtime admission owns continuation availability. ## Session media references diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9259bdad17..5b18b4aa38 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。对冷 Session,列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。已接入 Session 从其内存日志生成摘要。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。对冷 Session,列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。已接入 Session 从其内存日志生成摘要。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。凭证指向一个暂存文件或一张暂存图片;图片凭证与编码图片一样要经过图片输入检查。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和由 `origin: 'subagent'` 确定的 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/events`、已弃用的 `session/page`、`session/observeEvent` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections`、`session/observeProjections`、`session/state`、`session/observeState` 与 `session/wait` 从 `request.sessionId` 读取会话资源,因此每次调用与订阅都只点名它可读的会话。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 @@ -76,14 +76,14 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 `session.attachment` 接收 `{ sessionId, attachment: ImageAttachmentRef }`,并把完整引用交给 `AttachmentStore.readImage()`,不读取或遍历 Session 历史。Client 通过 `session.readAttachment(ref)` 传入消息中的引用。连接身份认证和 Gateway 权限检查仍然适用;`sessionId` 为该接口的权限检查指定 Session 资源。授权通过后,处理函数不检查该 Session 是否存在,也不会仅因其不存在而返回 `session/not-found`。获准调用的身份可以读取同一存储中的任意图片,包括仅被其他 Session 引用或未被任何 Session 引用的图片。响应在把 ID、媒体类型、字节数、宽度和高度与存储字节校验后,保留传入的引用。可选的 `name` 和 `originalDimensions` 作为调用方提交的显示信息原样返回,不根据文件校验。对象缺失或无效时返回明确的附件失败。Client 的历史图片缓存按 Session 和附件 ID 保留一份结果与浏览器 URL。 -本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——从 Session header 的血缘字段读出,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。 +本控制器通过 `ctx.plugin` 组合 `ArchivedSessionGate`:在 Agent 注册表、Session store 与 Workspace 注册表就绪后加载,随控制器一起释放。它的 `agent/pre-step` 监听器会拒绝为已归档会话或其子代理子孙——沿可用的 Session header 读取,从不包括 fork——提出的步骤,因此迟到的唤醒投递会让该回合以 `blocked` 收口而不发出模型请求;取消归档即为整条血缘解除门禁。Subagent runtime 另行检查保留的创建祖先,覆盖中间创建者已离线的托管后代。已归档会话仍在跑的工作由各自的 owner 通过 Workspace 注册表的归档准入([接缝](../../workspace/workspace/README.zh.md))报告与停止:运行中的回合由 [Agent 注册表](../../core/agent/README.zh.md)负责,所属任务由[任务注册表接缝](../../jobs/jobs/README.zh.md)负责,子代理子孙由 [Subagent](../../subagent/subagent/README.zh.md) runtime 负责,提醒由 [Schedule](../../schedule/schedule/README.zh.md) 插件负责;本控制器自己不报告任何内容。 ## Client 引用 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级的一致性;访问权单独按子 Session 检查。成功恢复 descriptor 后确定展示模式;失败只影响该子会话。控制请求仅携带目标 id,由 Host 检查是否支持继续执行。Client 快照仅保留子代理地址;是否能接收续接输入由运行时校验。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index d16e041773..343ad9ba5f 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -92,21 +92,11 @@ export type ApiSessionAgentResult = /** * Test whether generic Session routing must leave an identity to subagent routing. - * @param ctx - Host context carrying the Agent ownership registry. - * @param session - attached or live Session whose ownership is tested. - * @param agent - live Agent when one exists for the Session. + * @param session - Attached or live Session whose origin is tested. * @returns whether subagent routing owns the Session identity. */ -export function hasApiSessionSubagentOwner( - ctx: Context, - session: Pick, - agent: Agent | undefined, -): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +export function hasApiSessionSubagentOwner(session: Pick): boolean { + return session.header.origin === 'subagent' } /** @@ -178,7 +168,7 @@ export class ApiSessionAgentController { const live = this.liveAgent(sessionId) if (live !== undefined) return live const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + if (attached !== undefined && hasApiSessionSubagentOwner(attached)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } @@ -203,7 +193,7 @@ export class ApiSessionAgentController { const raced = this.liveAgent(sessionId) if (raced !== undefined) return raced const racedSession = this.ctx.sessions.get(sessionId) - if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) { + if (racedSession !== undefined && hasApiSessionSubagentOwner(racedSession)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { @@ -286,7 +276,7 @@ export class ApiSessionAgentController { private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { const agent = this.ctx.agents.get(sessionId) if (agent === undefined) return undefined - return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) + return hasApiSessionSubagentOwner(agent.session) ? { error: apiSessionSubagentOwnershipError(sessionId) } : { agent } } @@ -312,13 +302,12 @@ export class ApiSessionAgentController { if (observation.header.id !== sessionId || observation.header.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } - if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + if (hasApiSessionSubagentOwner({ header: observation.header })) { throw new ApiSessionSubagentOwnership(sessionId) } const composition = await this.composeAgent(this.presetForObservation(observation)) const published = this.ctx.sessions.get(sessionId) - const live = this.ctx.agents.get(sessionId) - if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { + if (published !== undefined && hasApiSessionSubagentOwner(published)) { throw new ApiSessionSubagentOwnership(sessionId) } return (await this.ctx.agents.resume({ @@ -343,7 +332,7 @@ export class ApiSessionAgentController { private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { + if (attached !== undefined && hasApiSessionSubagentOwner(attached)) { throw new ApiSessionSubagentOwnership(sessionId) } if (live !== undefined) { @@ -363,7 +352,7 @@ export class ApiSessionAgentController { let reusable: boolean { using observed = observation - if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { + if (hasApiSessionSubagentOwner({ header: observed.header })) { throw new ApiSessionSubagentOwnership(sessionId) } if (observed.header.cwd !== request.cwd) { diff --git a/packages/api/session-controller/src/archived-session-gate.ts b/packages/api/session-controller/src/archived-session-gate.ts index 06c6ee5f02..b1f556b397 100644 --- a/packages/api/session-controller/src/archived-session-gate.ts +++ b/packages/api/session-controller/src/archived-session-gate.ts @@ -4,7 +4,9 @@ * it is restored. The work a Session still runs is reported and stopped by * its owners — the Agent registry (`turn`), the job registry seam (`job`), * the Subagent runtime (`subagent`), and the Schedule plugin (`schedule`) — - * through the Workspace registry's archive-admission events. + * through the Workspace registry's archive-admission events. The Subagent + * runtime also checks its retained ancestry before each step, so a released + * intermediate Session cannot hide an archived ancestor. */ import type { Context, Plugin } from '@deepseek-ai/cordis' @@ -33,9 +35,10 @@ export const ArchivedSessionGate: Plugin.Object = { /** * Whether the Agent's Session, or a Session above it in its subagent lineage, - * is archived. Lineage follows the durable header fields through + * is archived. This header-only check follows the durable fields through * subagent-origin Sessions only: a fork of an archived Session is an - * independent conversation. + * independent conversation. The Subagent runtime separately checks managed + * ancestry when an intermediate Session is unavailable here. * @param ctx - Host context. * @param agent - the Agent proposing a step. * @returns whether an archived Session owns the step. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index 78e0c8c8a3..4912d19b16 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -73,8 +73,6 @@ export interface SessionSnapshot { readonly running: boolean readonly subagent: { readonly address: SubagentAddress - /** Absent until the direct-parent catalog resolves. */ - readonly parentAvailable?: boolean } | null readonly removed: boolean readonly openState: OpenState diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index ecb6a0e127..e7450ca973 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -147,12 +147,7 @@ export class SessionManager { throw new Error(`sessions.retain: unknown session ${id}`) } if (address !== undefined) this.addresses.set(id, address) - this.sessions.get(id)?.configureSubagent( - address, - address === undefined - ? undefined - : this.agentAvailable(address.parentSessionId), - ) + this.sessions.get(id)?.configureSubagent(address) return id } @@ -276,14 +271,8 @@ export class SessionManager { private createSession(sessionId: SessionId): Session { const address = this.addresses.get(sessionId) - const parentAvailable = address === undefined - ? undefined - : this.agentAvailable(address.parentSessionId) return new Session(sessionId, this.remote, { - ...(address === undefined ? {} : { - address, - ...parentAvailable === undefined ? {} : { parentAvailable }, - }), + ...(address === undefined ? {} : { address }), // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { @@ -400,18 +389,6 @@ export class SessionManager { return operation } - private agentAvailable(sessionId: SessionId): boolean | undefined { - return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable - ?? (this.listPhase === 'ready' ? false : undefined) - } - - private updateParentAvailability(): void { - for (const [childId, address] of this.addresses) { - const available = this.agentAvailable(address.parentSessionId) - if (available !== undefined) this.sessions.get(childId)?.handleSubagentParentAvailable(available) - } - } - // ---- List API ---- /** Full refresh via session.list (single-flight within one Host generation). */ @@ -446,7 +423,7 @@ export class SessionManager { for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained) this.listState = 'idle' this.listPhase = 'ready' - this.updateParentAvailability() + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) @@ -599,7 +576,6 @@ export class SessionManager { */ private mergeSummary(summary: SessionSummary): void { this.recordMutation({ kind: 'upsert', summary }) - this.updateParentAvailability() } /** Apply immediately and retain for replay when a list response is in flight. */ @@ -721,11 +697,6 @@ export class SessionManager { this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) - for (const [childId, address] of this.addresses) { - if (address.parentSessionId === sessionId) { - this.sessions.get(childId)?.handleSubagentParentAvailable(false) - } - } } /** @@ -736,7 +707,6 @@ export class SessionManager { handleSessionStatus(sessionId: SessionId, running: boolean): void { if (!this.disposed && running) this.engagedSessions.add(sessionId) this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true }) - this.updateParentAvailability() this.sessions.get(sessionId)?.handleRunning(running) } diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index 8e084384d4..bbe026bf4e 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -30,10 +30,8 @@ export interface SessionSubagentsRemote { request: SubagentPromptRequest, signal?: AbortSignal, ): Promise> - interruptByParent( + interruptByUser( childSessionId: SessionId, - parentSessionId: SessionId, - mode: 'continuable', ): Promise> } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 253da9c463..e74dd84422 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -71,8 +71,6 @@ interface PendingHistory { export interface SessionOptions { /** Catalog-discovered address selecting non-activating subagent transport. */ address?: SubagentAddress - /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ - parentAvailable?: boolean /** * Publish each accepted prompt to the Manager, including after this Session * object is replaced. Acceptance converts display state, but does not @@ -113,7 +111,6 @@ export class Session implements SessionFace { private readonly assistantStream = new ClientAssistantStream() private running = false private address: SubagentAddress | undefined - private parentAvailable: boolean | undefined /** * Sticky send marker, private input of the composerPhase derivation: set * when the Host accepts a prompt, never reset — the blank → engaging edge of @@ -193,7 +190,6 @@ export class Session implements SessionFace { ) { this.projections = options.projections ?? new ProjectionValueStore() this.address = options.address - this.parentAvailable = options.parentAvailable this.notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() }) @@ -294,9 +290,7 @@ export class Session implements SessionFace { const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ requestId: requestId ?? randomUUID() as SessionRequestId, - parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, - mode: 'continuable', delivery: mode, content: routedContent, clientTimeZone: resolvedClientTimeZone(), @@ -363,18 +357,14 @@ export class Session implements SessionFace { /** * Stop the active turn while the Host preserves pending inbox work; failures * land in promptError (same error-strip display slot). A subagent address - * routes through `subagents.interruptByParent`, whose durable parent-address - * authority works without a live parent Agent. + * routes through `subagents.interruptByUser`, authorized on the target Session + * without requiring a live parent Agent. * @returns the cancel result. */ async cancel(): Promise> { const address = this.address const result = address !== undefined - ? await this.remote.subagents.interruptByParent( - address.childSessionId, - address.parentSessionId, - 'continuable', - ) + ? await this.remote.subagents.interruptByUser(address.childSessionId) : await this.remote.session.cancel({ sessionId: this.sessionId }) if (!result.ok) { this.promptError = { op: 'stop', error: result.error } @@ -562,28 +552,16 @@ export class Session implements SessionFace { * Install or clear the catalog-discovered transport address. A changed * address rebuilds an already-open window through its new history route. * @param address - direct parent/child address, or undefined for ordinary transport. - * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. */ - configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { + configureSubagent(address: SubagentAddress | undefined): void { const same = this.address?.parentSessionId === address?.parentSessionId && this.address?.childSessionId === address?.childSessionId && this.address?.mode === address?.mode this.address = address - this.parentAvailable = parentAvailable if (!same && this.openState !== 'cold') void this.resync() else this.notifier.markDirty() } - /** - * Update only the parent availability hint from a catalog refresh. - * @param available - whether the exact direct parent is live. - */ - handleSubagentParentAvailable(available: boolean): void { - if (this.parentAvailable === available) return - this.parentAvailable = available - this.notifier.markDirty() - } - /** * Apply the Manager's effective display blank, further reconciled with the * current `sessionListMetadata` projection. Local send attempts and current @@ -934,7 +912,6 @@ export class Session implements SessionFace { address: this.address.mode === 'unknown' && identity != null ? { ...this.address, mode: identity.mode } : this.address, - ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, openState: this.openState, diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 5309872331..d306953c8c 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -409,7 +409,7 @@ export class SessionCommandController { } agent = found.agent } - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + if (hasApiSessionSubagentOwner(agent.session)) { const identity = this.ctx.sessionProjections .snapshot(agent.session, ['subagent']) .values.subagent @@ -470,7 +470,7 @@ export class SessionCommandController { { sessionId: request.sessionId }, ) } - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + if (hasApiSessionSubagentOwner(agent.session)) { throw apiSessionSubagentOwnershipError(request.sessionId) } agent.cancel({ kind: 'user' }, { keepInbox: true }) diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index d589dfd5ca..0cba6939d7 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -70,7 +70,7 @@ async function harness(rows: readonly Row[], summaryMs = 8, workSliceMs = 8) { ctx.sessions.announce(session) const agent = { id: session.id, session, ctx, status: 'idle' as Agent['status'] } // Only registry lookup and status are consumed; no Agent execution occurs in this fixture. - const detachAgent = ctx.agents.enter(agent as Agent, undefined) + const detachAgent = ctx.agents.enter(agent as Agent) ctx.effect(() => detachAgent, 'list-scheduling.agent') agents.set(id, agent) detach.set(id, () => { detachAgent(); detachSession() }) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 1946dfdeca..4e64470548 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -862,7 +862,6 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable', }, - parentAvailable: true, }) // Clicking the same child through an ordinary list-selection path must not // erase the catalog-derived address and fall back to session.* transport. @@ -874,7 +873,6 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable', }, - parentAvailable: true, }) await manager.get(S2).open() await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') @@ -895,8 +893,7 @@ describe('subagent catalogs', () => { expect(remote.subagents.prompt.mock.calls.map(([request]) => request)).toEqual([ { requestId: expect.any(String) as unknown as string, - parentSessionId: S1, childSessionId: S2, - mode: 'continuable', + childSessionId: S2, delivery: 'queue', content: [{ type: 'text', text: 'continue' }], clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, @@ -1061,36 +1058,6 @@ describe('subagent catalogs', () => { expect(remote.session.projections).toHaveBeenCalledTimes(2) }) - it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => { - const response = Promise.withResolvers>>() - remote.session.projections.mockImplementation(() => response.promise) - const manager = makeManager(mock, remote) - manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) - const child = manager.get(S2) - const read = manager.refreshProjections(S1) - manager.handleSessionAdded(summary(S1)) - manager.handleSessionStatus(S1, false) - response.resolve(failure - ? err(new RemoteError('gateway/internal', 'offline', {})) - : ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) - await read - expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) - expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) - }) - - it('keeps parent availability unknown after list failure and accepts a later Host summary', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) - const child = manager.get(S2) - remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'offline', {}))) - await manager.refreshList() - expect(manager.getListSnapshot().state).toBe('error') - expect(child.getSnapshot().subagent?.parentAvailable).toBeUndefined() - manager.handleSessionAdded(summary(S1)) - expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) - await manager.dispose() - }) - it('keeps a pushed catalog when an earlier initial read reports the parent missing', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -1260,23 +1227,6 @@ describe('remaining branches', () => { await manager.dispose() }) - it('preserves Host availability when a local create echo follows Agent disposal', async ({ mock, remote }) => { - const response = Promise.withResolvers>>() - remote.session.create.mockImplementation(() => response.promise) - const manager = makeManager(mock, remote) - manager.resolveTarget({ - parentSessionId: S1, childSessionId: S2, mode: 'continuable', - }) - const child = manager.get(S2) - const request = manager.create() - manager.handleSessionAdded(summary(S1, { agentAvailable: false })) - response.resolve(ok({ sessionId: S1 })) - await request - manager.resolveTarget(S2) - expect(child.getSnapshot().subagent?.parentAvailable).toBe(false) - await manager.dispose() - }) - it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => { remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', { sessionId: S1, workspaceId: 'w1', @@ -1631,7 +1581,6 @@ describe('connected generation', () => { }) expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, - parentAvailable: true, }) expect(manager.subagentAddress(S2)).toEqual(address) }) diff --git a/packages/api/session-controller/tests/remote/bench.client.ts b/packages/api/session-controller/tests/remote/bench.client.ts index 6e0f10d569..a696fd0c28 100644 --- a/packages/api/session-controller/tests/remote/bench.client.ts +++ b/packages/api/session-controller/tests/remote/bench.client.ts @@ -11,7 +11,7 @@ import { sessionWorld } from './session.client.ts' * @param mock - this case's Remote responses. * @param start - the fixture's lazy client startup. * @param sessionId - Session identity. - * @param options - addressed child and parent availability, when applicable. + * @param options - addressed child and other Session options, when applicable. * @returns the Session, disposed at the end of the case. */ export async function sessionBench( diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index f920d1592e..c4d8049177 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -147,7 +147,7 @@ export const sessionWorld: RemoteTable = { 'session/page': pageRule(ok({ cursor: -1, records: [], hasMore: false })), 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), - 'subagents/interruptByParent': ok({ accepted: true }), + 'subagents/interruptByUser': ok({ accepted: true }), }, stream: { 'session/follow': followScript(ok({ cursor: -1, records: [], hasMore: false })), diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 7cfc874c14..49e0b3210d 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -673,10 +673,10 @@ describe('subagent ownership fence', () => { await ctx.agents.register(originChild) const startingSession = ctx.sessions.create(sid('session-starting-child'), { - meta: { cwd: '/proj', parentSession: parent.id }, + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent - ctx.agents.enter(startingChild, parent) + ctx.agents.enter(startingChild) const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const stopped = await remote.cancel(request({ sessionId: originChild.id })) diff --git a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts index a6e7a274aa..2fb870f910 100644 --- a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts +++ b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts @@ -7,21 +7,28 @@ */ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { Session } from '../src/client/sessions/session.ts' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId -/** Every command rejects with `message`; nothing else on the face is reachable from prompt or cancel. */ +/** Calls used by Session prompt and cancel reject with `message`. */ function rejecting(message: string): SessionRemotes { const reject = (): Promise => Promise.reject(new Error(message)) + const { remote } = RemoteMock.create() + remote.commands.execute.mockImplementation(reject) + remote.session.prompt.mockImplementation(reject) + remote.session.cancel.mockImplementation(reject) + remote.subagents.prompt.mockImplementation(reject) + remote.subagents.interruptByUser.mockImplementation(reject) return { $stream: () => { throw new Error('unused') }, - commands: { execute: reject }, - session: { prompt: reject, cancel: reject }, - subagents: { list: reject, prompt: reject, interruptByParent: reject }, - } as unknown as SessionRemotes + commands: remote.commands, + session: remote.session, + subagents: remote.subagents, + } } describe('Session over a rejecting Remote face', () => { @@ -35,7 +42,6 @@ describe('Session over a rejecting Remote face', () => { it('propagates the rejection from a subagent continuation prompt and interrupt', async () => { const session = new Session(SID, rejecting('assembly fault'), { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, }) await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') await expect(session.cancel()).rejects.toThrow('assembly fault') diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 70c69ca033..e118ef29e5 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -474,7 +474,7 @@ describe('prompt and cancel errors', () => { const CHILD = { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' } as const it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer') @@ -491,34 +491,34 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - ...CHILD, + childSessionId: SID, delivery: 'queue', content: [{ type: 'text', text: '继续' }], clientTimeZone: TIME_ZONE, }, { requestId: expect.any(String) as unknown as string, - ...CHILD, + childSessionId: SID, delivery: 'steer', content: [{ type: 'text', text: '现在处理' }], clientTimeZone: TIME_ZONE, }, ]) // Positional parameters cross the wire as positional args. - expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.calls('subagents/interruptByUser').map(call => call.args)).toEqual([[SID]]) expect(mock.log.requests('session/prompt')).toEqual([]) expect(mock.log.requests('session/cancel')).toEqual([]) // A successful interrupt leaves no stop error behind. expect(session.getSnapshot().promptError).toBeNull() - expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) + expect(session.getSnapshot().subagent).toEqual({ address: CHILD }) }) it('sends an addressed child continuation under its echo request id and retires a refused echo', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const accepted = session.beginSubmission({ mode: 'queue', text: '继续', attachments: [] }) await session.prompt([{ type: 'text', text: '继续' }], 'queue', undefined, accepted.requestId) - expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, ...CHILD }]) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ requestId: accepted.requestId, childSessionId: SID }]) expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([accepted.requestId]) const refused = session.beginSubmission({ mode: 'queue', text: '附件', attachments: [] }) @@ -527,7 +527,7 @@ describe('prompt and cancel errors', () => { }) it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const content = [ { type: 'text' as const, text: '看这张图' }, @@ -539,7 +539,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - ...CHILD, + childSessionId: SID, delivery: 'queue', content, clientTimeZone: TIME_ZONE, @@ -549,18 +549,18 @@ describe('prompt and cancel errors', () => { }) it('lands an interrupt business failure in promptError with op=stop', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) - mock.remote.subagents.interruptByParent.mockResolvedValue(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) + const session = await sessionBench(mock, start, SID, { address: CHILD }) + mock.remote.subagents.interruptByUser.mockResolvedValue(err(new RemoteError('remote-access/denied', 'nope', { action: 'subagents/interruptByUser', reason: 'no-allow' }))) await session.open() const cancelled = await session.cancel() - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'remote-access/denied' } }) expect(session.getSnapshot().promptError).toMatchObject({ - op: 'stop', error: { code: 'subagent/unauthorized' }, + op: 'stop', error: { code: 'remote-access/denied' }, }) }) it('rejects staged files instead of dropping them from subagent continuations', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt([ @@ -578,7 +578,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toEqual([]) }) - it('sends a one-shot address to the Host under the continuable marker', async ({ mock, start }) => { + it('lets the Host validate the target continuation state', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID, { address: { ...CHILD, mode: 'one-shot' } }) mock.remote.subagents.prompt.mockResolvedValue(err(new RemoteError( 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID }, @@ -587,11 +587,10 @@ describe('prompt and cancel errors', () => { const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const cancelled = await session.cancel() - // The Host reads the durable descriptor; the wire marker stays 'continuable'. expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) - expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.requests('subagents/prompt')).toMatchObject([{ childSessionId: SID }]) + expect(mock.log.calls('subagents/interruptByUser').map(call => call.args)).toEqual([[SID]]) expect(mock.log.requests(FOLLOW)).toEqual([ { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, activate: true, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) diff --git a/packages/api/session-controller/tests/subagent-access.host.spec.ts b/packages/api/session-controller/tests/subagent-access.host.spec.ts new file mode 100644 index 0000000000..ad077c695b --- /dev/null +++ b/packages/api/session-controller/tests/subagent-access.host.spec.ts @@ -0,0 +1,128 @@ +/** Target Session permissions are enforced before subagent input or cancellation is admitted. */ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { RemoteAccessBinding } from '@deepseek-ai/dsh-api-remote-access' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SubagentRuntime from '@deepseek-ai/dsh-subagent' +import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts' +import { createSessionTestController } from './test-remote.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +const GUEST: Principal = { kind: 'test-guest', guest: 'subagent-controls' } +const ACTIONS = ['subagents/prompt', 'subagents/interruptByUser'] as const +const cleanups: Array<() => Promise> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function harness() { + const root = await mkdtemp(join(tmpdir(), 'dsh-subagent-access-')) + const ctx = new Context() + cleanups.push(async () => { + try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } + }) + await ctx.plugin(TypertRegistry) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + await mountWorkingDirectoryFixture(ctx) + await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: root }) + await ctx.plugin(SubagentRuntime) + ctx.subagents.registerProvider({ + name: 'test', inheritsParentContext: false, + capabilities: { agentOptions: true, agentPreset: false, outputSchema: false, depthLimit: false }, + prepareContinuable: async () => ({}), + }) + const adapter = new MockAdapter(['hang']) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = await ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + const run = await ctx.subagents.startActivation({ + provider: 'test', label: 'access target', delivery: 'caller', + parent, prompt: [{ type: 'text', text: 'hold' }], + signal: new AbortController().signal, + }) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(run.childId)! + await ctx.plugin(TypertGatewayService) + const bindings: RemoteAccessBinding[] = [] + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => bindings }) + const call = (method: 'prompt' | 'interruptByUser', principal: Principal = GUEST, image = false) => { + const peer: PeerScope = { + id: 'subagent-access-peer' as PeerId, ctx, principal, interactions: 'answer', + signal: new AbortController().signal, dispose: async () => {}, + } + return ctx.typertGateway.invoke({ + peer, namespace: 'subagents', method, + args: method === 'prompt' ? { request: { + requestId: 'access-follow-up', childSessionId: child.id, delivery: 'queue', + content: image ? [{ type: 'image', mediaType: 'image/png', data: 'aGk=' }] : [{ type: 'text', text: 'follow-up' }], + } } : { childSessionId: child.id }, + }) + } + return { ctx, parent, child, bindings, call } +} + +describe('subagent target Session access', () => { + it.each(['prompt', 'interruptByUser'] as const)('requires the target permission for %s', async (method) => { + const { ctx, parent, child, bindings, call } = await harness() + const attachments = vi.spyOn(ctx.attachments, 'admitPromptContent') + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const cancel = vi.spyOn(child, 'cancel') + const grants: RemoteAccessBinding[][] = [ + [], + [{ role: { name: 'global controls', resource: 'global', actions: ACTIONS }, resource: { kind: 'global' } }], + [{ role: { name: 'parent controls', resource: 'session', actions: ACTIONS }, resource: { kind: 'session', id: parent.id } }], + [{ role: { name: 'other control', resource: 'session', actions: [method === 'prompt' ? ACTIONS[1] : ACTIONS[0]] }, resource: { kind: 'session', id: child.id } }], + ] + for (const grant of grants) { + bindings.splice(0, bindings.length, ...grant) + await expect(call(method, GUEST, true)).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: `subagents/${method}`, reason: 'no-allow' }, + }) + } + expect(attachments).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(cancel).not.toHaveBeenCalled() + expect(child.inbox.nextTurn).toHaveLength(0) + + bindings.splice(0, bindings.length, { + role: { name: 'target control', resource: 'session', actions: [`subagents/${method}`] }, + resource: { kind: 'session', id: child.id }, + }) + const accepted = await call(method) + if (method === 'prompt') { + expect(child.inbox.nextTurn).toHaveLength(1) + expect(accepted).toEqual({ messageId: child.inbox.nextTurn[0]?.id }) + expect(child.inbox.nextTurn[0]?.source).toMatchObject({ kind: 'user', principal: GUEST }) + } else { + expect(accepted).toEqual({ accepted: true }) + expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + await child.whenIdle() + } + }) + + it('allows the owner without target bindings', async () => { + const { child, call } = await harness() + const accepted = await call('prompt', { kind: 'owner' }) + expect(child.inbox.nextTurn).toHaveLength(1) + expect(accepted).toEqual({ messageId: child.inbox.nextTurn[0]?.id }) + await expect(call('interruptByUser', { kind: 'owner' })).resolves.toEqual({ accepted: true }) + await child.whenIdle() + }) +}) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 2568f3f807..db0857e5b7 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -83,7 +83,7 @@ function sessionClient(remote: SessionTransportRemote): SessionRemotes { commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) }, subagents: { prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')), - interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')), + interruptByUser: () => Promise.reject(new Error('stream tests never interrupt a subagent')), }, } } diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 4b3f0d24d3..979d0867fb 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/README.i18n.yaml @@ -9,14 +9,14 @@ en: 7fecb4d8800e8f97 zh: 3e2c3fdb29b73cd0 /settings-controller/summary: - en: e5068267079e62d0 - zh: 9e2d663a6ca02c58 + en: 94a995854f855609 + zh: 7165eecdd85a5632 /settings-controller/table-of-contents: en: 3a654e12f423946b zh: 4e3a19aaee58f8a7 /settings-controller/use-this-package: - en: cf2f4de5bee8be55 - zh: b2630dcba4be5ae8 + en: d6fbd0324044be6b + zh: 5e86d8c95e51b421 /settings-controller/configuration: en: e0297f144b649af7 zh: e70ed56edffd97f0 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index b38072546d..17357971ed 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports settings and credential writes without returning secret values, and opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. +`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata and supports settings and credential writes. It reads stored keys for owner-opened editors. It also opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) Mount this package as a Loader entry in a profile that serves browser configuration. The entry registers both namespaces independently of their providers so a missing provider produces a named configuration error at invocation. Its generated descriptors enter the strict Typert registry, while the settings and credential Definitions remain plain Cordis Services with no wire obligations of their own. -`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. Secret values cross in this direction only: no method here returns one. +`describe(refs)` answers one map keyed by the requested names, so a settings page describing every reference its rows carry settles those rows together. It accepts at most 64 names per call, reports an invalid name or empty write value as `bad-request`, and copies each answer field by field — a provider returning more than `CredentialInfo` declares cannot widen what crosses. Valid `set(ref, value)` and `unset(ref)` calls report a provider refusal as `credential-rejected`, carrying the provider's message with only the reference in its details. `readStored(ref)` returns a provider-managed key only for the owner or trusted in-process code; it returns no environment fallback. A non-owner read returns `credential/rejected` before consulting the provider. Metadata reads remain secret-free. `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 3c9e876740..2c4f4a2b29 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入而不返回机密值,并在 Host 桌面打开由提供方持有的 settings 或 Agent preset 位置。提供方缺失时,namespace 仍会注册,并返回可操作的配置错误。 +`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入。它为所有者打开的编辑器读取已保存密钥。它还在 Host 桌面打开由提供方持有的 settings 或 Agent preset 位置。提供方缺失时,namespace 仍会注册,并返回可操作的配置错误。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" 请把本包作为 Loader entry 挂载到提供浏览器配置的 profile 中。本 entry 不依赖提供方是否存在而注册两个 namespace,因此缺少提供方会在调用时产生具名配置错误。它生成的 descriptor 进入严格 Typert 注册表,而 settings 与凭据 Definition 仍是普通 Cordis 服务,自身不承担任何 wire 义务。 -`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。机密值只在这个方向跨越 wire:这里没有任何方法会返回它。 +`describe(refs)` 以请求的名字为键返回一份 map,因此设置页描述其各行携带的全部引用时,这些行会一起落定。单次调用最多接受 64 个名字,无效名字或空写入值报告为 `bad-request`,并逐字段复制每个答案——提供方返回超出 `CredentialInfo` 声明的内容也无法扩大跨越 wire 的字段。有效的 `set(ref, value)` 与 `unset(ref)` 调用把提供方拒绝报告为 `credential-rejected`,携带提供方的消息,details 中只有该引用。`readStored(ref)` 仅向所有者或可信进程内代码返回提供方管理的密钥,不返回环境后备值。非所有者读取会在访问提供方之前返回 `credential/rejected`。元数据读取仍不包含机密。 `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings 服务的三种写入操作,并返回该 namespace 的新脱敏视图;陈旧写入使用 `settings-conflict`,其他提供方拒绝使用 `settings-rejected`。 diff --git a/packages/api/settings-controller/src/credentials.ts b/packages/api/settings-controller/src/credentials.ts index 3889669048..f9172f8f9e 100644 --- a/packages/api/settings-controller/src/credentials.ts +++ b/packages/api/settings-controller/src/credentials.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { principalOf, Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' /** @@ -61,8 +61,8 @@ declare module '@deepseek-ai/cordis' { * Host service backing the generated `ctx.remote.credentials` namespace. It * carries every wire obligation the credential seam itself does not: the batch * fan-out bound, the field-by-field view projection, the reference-grammar - * guard, and the refusal mapping. Secret values cross in one direction only — - * no method here returns one. + * guard, and the refusal mapping. Metadata reads exclude secrets; readStored + * returns only a provider-managed value for an explicitly opened editor. */ export class CredentialsController extends TypertRemoteService { /** @param ctx - Host context where a credential provider may be mounted. */ @@ -90,8 +90,22 @@ export class CredentialsController extends TypertRemoteService { } /** - * Store one value from a configuration surface. The value crosses the wire in - * this direction only: no read path returns it. + * Read one stored value for a credential editor, excluding environment fallbacks. + * @param ref - reference name to read. + * @returns the stored secret, or undefined when the provider stores none. + * @throws RemoteError for a non-owner Remote caller, an invalid reference, or an absent provider. + */ + @Remote + async readStored(ref: string): Promise { + const request = parseRequest('credentials.readStored', unsetRequestSchema, { ref }) + if (principalOf(this.ctx.invocation) !== undefined) { + throw new RemoteError('credential/rejected', 'Only the owner can read stored credentials.', { ref: request.ref }) + } + return this.provider().readStored(credentialRef(request.ref)) + } + + /** + * Store one value from a configuration surface. * @param ref - reference name to store under. * @param value - the non-empty secret value. * @throws RemoteError when the request is invalid, no provider is mounted, or the provider refuses the write. diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 3d11c6ab0c..d1dadc1e16 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -20,9 +20,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { */ 'settings/conflict': { readonly ns: string; readonly expected: number; readonly actual: number } /** - * The provider refused a valid credential write, for example because the - * stored document no longer parses. The details name only the reference, - * never the value. + * A non-owner requested a stored secret, or the provider refused a valid + * credential write, for example because the stored document no longer + * parses. The details name only the reference, never the value. */ 'credential/rejected': { readonly ref: string } } diff --git a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts index 5eeb190385..552baa7b5d 100644 --- a/packages/api/settings-controller/tests/credentials-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/credentials-controller.host.spec.ts @@ -2,9 +2,17 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import { remoteErrorOf, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerId, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import CredentialsController from '../src/credentials.ts' import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts' +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Non-owner used to verify stored-key read denial. */ + 'credential-viewer': { readonly kind: 'credential-viewer' } + } +} + /** A store whose `describe` carries more than the view declares, as a foreign provider might. */ class LeakyCredentials extends MemoryCredentials { override describe(): Promise { @@ -46,6 +54,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => expect(binding.namespace).toBe('credentials') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'readStored', invocation: { kind: 'direct' } }, { method: 'set', invocation: { kind: 'direct' } }, { method: 'unset', invocation: { kind: 'direct' } }, ]) @@ -56,6 +65,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => await ctx.plugin(CredentialsController) for (const call of [ () => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']), + () => ctx.credentialsController.readStored('DEEPSEEK_API_KEY'), () => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'), () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'), ]) { @@ -82,6 +92,7 @@ describe('the credentials Remote namespace a configuration surface calls', () => const controller = await boot() for (const call of [ () => controller.describe(['DEEPSEEK_API_KEY', 'not a var']), + () => controller.readStored('not a var'), () => controller.set('not a var', 'sk-live'), () => controller.unset('not a var'), ]) { @@ -115,6 +126,35 @@ describe('the credentials Remote namespace a configuration surface calls', () => .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } }) }) + it('reads stored secrets only through the explicit editor read', async () => { + const controller = await boot({ DEEPSEEK_API_KEY: 'fixture-saved-key' }) + expect(await controller.readStored('DEEPSEEK_API_KEY')).toBe('fixture-saved-key') + expect(await controller.readStored('OPENAI_API_KEY')).toBeUndefined() + expect(JSON.stringify(await controller.describe(['DEEPSEEK_API_KEY']))).not.toContain('fixture-saved-key') + await controller.unset('DEEPSEEK_API_KEY') + expect(await controller.readStored('DEEPSEEK_API_KEY')).toBeUndefined() + }) + + it('denies non-owner stored-key reads before consulting the provider', async () => { + const ctx = new Context() + await ctx.plugin(MemoryCredentials, { DEEPSEEK_API_KEY: 'fixture-saved-key' }) + await ctx.plugin(CredentialsController) + const signal = new AbortController().signal + const invocation: RemoteInvocation = { + request: { namespace: 'credentials', method: 'readStored', args: { ref: 'DEEPSEEK_API_KEY' } }, + service: 'credentialsController', signal, + peer: { + id: 'credential-viewer' as PeerId, ctx, signal, principal: { kind: 'credential-viewer' }, + interactions: 'none', dispose: () => Promise.resolve(), + }, + uplink: async function* (): AsyncGenerator {}, + } + const caller = ctx.extend({ invocation }) + const failure = await caller.credentialsController.readStored('DEEPSEEK_API_KEY').catch((error: unknown) => error) + expect(remoteErrorOf(failure)).toMatchObject({ code: 'credential/rejected', details: { ref: 'DEEPSEEK_API_KEY' } }) + expect(JSON.stringify(remoteErrorOf(failure))).not.toContain('fixture-saved-key') + }) + it('reports a refused write as credential/rejected naming only the reference', async () => { const controller = await boot({}, RejectingCredentials) const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 9ce0beae8e..98cb511540 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-app-boot/use-this-package: - en: f6fa378225009c8f - zh: 324c0e4640bb1358 + en: 8e42ac55d685e0f1 + zh: e9e8804af1b2df2b /deepseek-ai-dsh-app-boot/use-this-package/when-to-use-it: en: 2fdfe02f2ed4a258 zh: d748228ece579c4a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 4d30457f81..46fc9be865 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Starting an app with this package is a small, explicit entry point: you give it a config file and it runs the whole boot. This section covers what you can do and what you get; the helper calls behind each outcome are documented in the folded implementation section. +Profile loading removes retired installation-bundle selections from `dsh.profile.bundles` and rewrites the manifest only when needed, preserving other fields. [Team migration](../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.md) describes the default composition and retained row overrides. + ### When to use it Use it when implementing the shared `dsh` launcher or embedding its lower-level boot helpers. Product features belong in profile bundles instead of new application bins; code that only adds plugins to an already-running app mounts those plugins directly. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index a4d8554ca5..1bc7234f55 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -27,6 +27,8 @@ kind: "package-library" 用此包启动应用是一个小而显式的入口:你给它一个配置文件,它运行整个启动过程。本节说明你能做什么、能得到什么;每个结果背后的 helper 调用记录在下方可折叠的实现章节中。 +profile 加载时会从 `dsh.profile.bundles` 移除已停用的安装方 bundle 选项,仅在需要时重写 manifest,保留其他字段。[Team 迁移](../../../docs/upgrade-guide/v0.2.1-rc.2/session-team-mode/guide.zh.md)说明默认组合与保留的行覆盖。 + ### 何时使用 在实现共享 `dsh` launcher 或嵌入其低层启动 helper 时使用它。产品功能应放入 profile 组合包,而不是新增应用 bin;只向已运行应用添加插件的代码直接挂载插件即可。 diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0a67d476e1..238f316dc9 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -394,13 +394,13 @@ describe('loadProfile', () => { ]) }) - it('removes a retired bundle from an application-owned profile and keeps the rest of the manifest', () => { + it.each(['@deepseek-ai/dsh-experimental-schedule-bundle', '@deepseek-ai/dsh-experimental-agent-team-profile'])('removes retired %s and keeps the rest of an application-owned manifest', (retired) => { const anchor = stageInstallation({ '@deepseek-ai/dsh-base': { patch: '[]\n' }, 'custom-bundle': { patch: '[]\n' }, }) const dir = join(tmp(), 'app-profile') - initProfile(dir, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-experimental-schedule-bundle', 'custom-bundle']) + initProfile(dir, ['@deepseek-ai/dsh-base', retired, 'custom-bundle']) writeProfileManifest(dir, { ...readProfileManifest('t', dir), dependencies: { 'custom-bundle': '^1.0.0' } }) const profile = loadProfileDirectory('t', dir, anchor) expect(profile.skippedBundles).toEqual([]) diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ceb82aac47..b1d696ad74 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -522,7 +522,6 @@ export class PluginManager extends TypertRemoteService { catch (error) { platformError = managementError(error) } const error = platformError ?? targetError ?? (enabled ? { code: 'not-bundle' as const } : undefined) bundles.push({ name, official, availability: 'missing', ...catalogMeta, ...target, enabled, installed, optional, - ...offered.listedByDefault === undefined ? {} : { listedByDefault: offered.listedByDefault }, ...platformError?.code === 'unsupported-platform' ? { listedByDefault: false } : {}, removable: enabled, rows: [], overrides: [], ...error === undefined ? {} : { error } }) diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 207f34291f..8187c45a9b 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1811,7 +1811,6 @@ it('lists absent Official entries offline without resolving their packages or co expect(bundles.filter(bundle => bundle.name === entry.packageName)).toMatchObject([{ name: entry.packageName, official: true, availability: 'missing', enabled: false, installed: false, optional: false, removable: false, updatable: false, rows: [], overrides: [], meta: entry.meta, - ...entry.listedByDefault === undefined ? {} : { listedByDefault: entry.listedByDefault }, ...entry.platforms !== undefined && !entry.platforms.includes(process.platform) ? { listedByDefault: false, error: { code: 'unsupported-platform' } } : {}, installTarget: { spec: `${entry.packageName}@${getDshRuntimeVersion()}`, version: getDshRuntimeVersion() }, diff --git a/packages/boot/plugin-market/README.i18n.yaml b/packages/boot/plugin-market/README.i18n.yaml index fb47f93c88..e8352139a5 100644 --- a/packages/boot/plugin-market/README.i18n.yaml +++ b/packages/boot/plugin-market/README.i18n.yaml @@ -18,8 +18,8 @@ en: fef9850d643e327f zh: c6e8cab773cf93b9 /deepseek-ai-dsh-plugin-market/use-this-package/plugin-detail-documents: - en: 0e15de273c9acaae - zh: 4fd93c92b71d4398 + en: b5d96ca29cf76f31 + zh: e5099b4f694cf4c9 /deepseek-ai-dsh-plugin-market/understand-the-implementation: en: 678705f57d986377 zh: 68d090edfe04acde diff --git a/packages/boot/plugin-market/README.md b/packages/boot/plugin-market/README.md index b93adc2bf9..cdd9b211c2 100644 --- a/packages/boot/plugin-market/README.md +++ b/packages/boot/plugin-market/README.md @@ -72,7 +72,7 @@ Text matches the package name, author, keywords, and every language of the title Without any, the result is `{ status: 'none' }`. A found document carries its `origin` (`locale`, `readme`, or `repository`), its Markdown without a leading YAML front matter block, and a `document` id. Results, and the `too-large`, `denied`, and `invalid` failures that reading again would repeat, are cached for `cacheTtlMs`; `refresh` reads again instead, and concurrent reads share one read. A `document` id stays valid while a read returns it or an image request names it at least once every `cacheTtlMs`; an unused id is forgotten, and its images answer 404 until the document is read again. -Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url..insteadOf` rules without prompting, so an https URL can be routed to ssh; ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set. +Repository files are read through GitHub's raw file host for `github.com` and the GitLab repository files API for `detailGitlabHosts`. API requests follow redirects within their origin. When an API answers 401, 403, or 404, a request to the repository's smart HTTP endpoint without any credentials decides whether the repository is public; a public repository's miss means the file does not exist. A repository that needs credentials, and a repository on a host without such an API, is read from a partial clone over https, http, or ssh. Git uses the user's configuration, credential helpers, and `url..insteadOf` rules without prompting, so an https URL can be routed to ssh. When Git refuses an http(s) URL, the clone fetches again from `ssh://git@/`, the SSH form plugin installation also uses, and keeps that remote; a failed retry reports both diagnostics, as `network` or `too-large` for those causes and `denied` otherwise. Opening a detail page can therefore connect over ssh to the host an index entry names; with batch mode and the default host key checking, ssh authenticates only to hosts in the user's known hosts. ssh runs in batch mode unless `GIT_SSH` or `GIT_SSH_COMMAND` is set. `GET /api/plugin-market/detail-file?document=&path=` serves an image beside a read document to authenticated owner connections. The destination is relative to the document, or to the package or repository root when it starts with `/`; its query and fragment are ignored. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside that root are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and a private cache for `cacheTtlMs`. A missing file, unknown document, or other path answers 404; the failures below answer 413, 502, 403, 503, and 400; failure responses are not cached. diff --git a/packages/boot/plugin-market/README.zh.md b/packages/boot/plugin-market/README.zh.md index 5d82b46c77..ca213854bd 100644 --- a/packages/boot/plugin-market/README.zh.md +++ b/packages/boot/plugin-market/README.zh.md @@ -73,7 +73,7 @@ Remote 方法如下: 三者都没有时,结果为 `{ status: 'none' }`。找到的文档携带其 `origin`(`locale`、`readme` 或 `repository`)、去掉开头 YAML front matter 块后的 Markdown 内容和 `document` id。结果,以及再次读取也会重复出现的 `too-large`、`denied` 和 `invalid` 失败,缓存 `cacheTtlMs`;传入 `refresh` 时改为重新读取;并发读取共享一次读取。只要每个 `cacheTtlMs` 内至少有一次读取返回某个 `document` id 或有一次图片请求引用它,该 id 就保持有效;未使用的 id 会被遗忘,在再次读取该文档之前,其图片请求返回 404。 -`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url..insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问;除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。 +`github.com` 上的仓库文件通过 GitHub 的原始文件主机读取,`detailGitlabHosts` 中主机上的仓库文件通过 GitLab 仓库文件 API 读取。API 请求跟随同一 origin 内的重定向。API 返回 401、403 或 404 时,由一次不带任何凭据、发往该仓库 smart HTTP 端点的请求判断仓库是否公开;公开仓库中未找到文件即表示该文件不存在。需要凭据的仓库,以及所在主机没有此类 API 的仓库,通过 https、http 或 ssh 从部分克隆中读取。Git 使用用户的配置、凭据助手和 `url..insteadOf` 规则,且不提示输入,因此 https URL 可能被改为通过 ssh 访问。Git 拒绝 http(s) URL 时,部分克隆改从 `ssh://git@/`(插件安装也使用的 SSH 形式)再次获取,并保留该远程地址;重试失败时报告两次的诊断,原因是网络或大小限制时分别报告 `network` 或 `too-large`,否则报告 `denied`。因此打开详情页可能通过 ssh 连接索引条目指定的主机;在批处理模式和默认的主机密钥检查下,ssh 只向用户 known hosts 中的主机认证。除非设置了 `GIT_SSH` 或 `GIT_SSH_COMMAND`,ssh 以批处理模式运行。 `GET /api/plugin-market/detail-file?document=&path=` 向已通过认证的 owner 连接提供已读取文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包或仓库的根目录;路径中的查询和片段被忽略。只提供该根目录内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和时长为 `cacheTtlMs` 的私有缓存。文件不存在、文档未知或其他路径返回 404;下文所列的失败依次返回 413、502、403、503 和 400;失败响应不被缓存。 diff --git a/packages/boot/plugin-market/src/index.ts b/packages/boot/plugin-market/src/index.ts index f4f2a26c95..b64d36e702 100644 --- a/packages/boot/plugin-market/src/index.ts +++ b/packages/boot/plugin-market/src/index.ts @@ -142,7 +142,7 @@ export default class PluginMarket extends TypertRemoteService { const lifetime = this.lifetime.signal this.repositories = new RepositoryCache({ dir: cacheDir, maxRepositoryBytes: config.detailRepositoryMaxBytes, maxCacheBytes: config.detailCacheMaxBytes, - // The URL forms repositories are read from, and ssh, which `url..insteadOf` may route them to. + // The URL forms repositories are read from, and ssh, which `url..insteadOf` or the retry after a refused http(s) fetch uses. timeoutMs: config.detailGitTimeoutMs, ttlMs: config.cacheTtlMs, transports: ['https', 'http', 'ssh'], lifetime, // Bounds how far a fetch overshoots `detailRepositoryMaxBytes`; directory walks cost little at this rate. sizePollMs: 200, diff --git a/packages/boot/plugin-market/src/repository-cache.ts b/packages/boot/plugin-market/src/repository-cache.ts index 87a9b51b15..944e79fbd0 100644 --- a/packages/boot/plugin-market/src/repository-cache.ts +++ b/packages/boot/plugin-market/src/repository-cache.ts @@ -15,10 +15,10 @@ import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import { DetailFailure, readmeName, type FoundFile } from '@deepseek-ai/dsh-plugin-detail' /** - * Git deadlines one operation's lock waits for: an operation runs at most a fetch and a file read that contact the - * remote, each bounded by the deadline, plus short local commands. + * Git deadlines one operation's lock waits for: an operation runs at most a fetch, its SSH retry, and a file read that + * contact the remote, each bounded by the deadline, plus short local commands. */ -const OPERATION_COMMANDS = 4 +const OPERATION_COMMANDS = 5 /** Limits, location, and transports of the clone cache. */ export interface RepositoryCacheOptions { @@ -68,6 +68,18 @@ function remoteFailureOf(result: GitResult, label: string): DetailFailure { return new DetailFailure(NETWORK_FAILURE.test(result.diagnostic) ? 'network' : 'denied', `${label}: ${result.diagnostic}`) } +/** + * The SSH form of an http(s) repository URL: `ssh://git@/` on SSH's default port, the form that + * `sshGitSpec` in `@deepseek-ai/dsh-plugin-manager` gives installation. + * @returns undefined for another scheme, or a URL with credentials or a query. + */ +function sshUrlOf(url: string): string | undefined { + if (!/^https?:/iu.test(url)) return undefined + const parsed = new URL(url) + if (parsed.username !== '' || parsed.password !== '' || parsed.search !== '') return undefined + return `ssh://git@${parsed.hostname}${parsed.pathname}` +} + /** A value reused until it expires. */ interface Expiring { readonly value: T @@ -259,17 +271,35 @@ export class RepositoryCache { const head = this.heads.get(url) const known = commit ?? (head !== undefined && head.expiresAt > Date.now() ? head.value : undefined) if (known !== undefined && !(await this.git(['show-ref', '--verify', '--quiet', `refs/dsh/${known}`], repository, signal)).failed) return known - await this.fetch(repository, url, commit ?? 'HEAD', signal) + await this.fetch(repository, commit ?? 'HEAD', signal) const fetched = commit ?? (await this.run(repository, ['rev-parse', 'FETCH_HEAD'], signal)).toString('utf8').trim() await this.run(repository, ['update-ref', `refs/dsh/${fetched}`, fetched], signal) if (commit === undefined) this.heads.set(url, { value: fetched, expiresAt: Date.now() + this.options.ttlMs }) return fetched } - /** Fetch one commit without file contents. */ - private async fetch(repository: string, url: string, target: string, signal: AbortSignal): Promise { + /** + * Fetch one commit without file contents. When Git refuses an http(s) URL, the clone's remote switches to the SSH form + * of that URL and the fetch runs again, so a user with only SSH credentials for the host can read it; later reads of + * the clone keep that remote. A failed SSH fetch reports both diagnostics, as `network` or `too-large` when it failed + * for that reason and as `denied` otherwise. + */ + private async fetch(repository: string, target: string, signal: AbortSignal): Promise { const args = ['fetch', '--quiet', '--depth', '1', '--filter=blob:none', '--no-tags', 'origin', target] - await this.watched(repository, url, args, signal, `${url}: git fetch`) + // The remote is the URL this cache wrote or its SSH form; 64 KiB holds any such URL. + const remote = (await this.run(repository, ['config', 'remote.origin.url'], signal, 64 * 1024)).toString('utf8').trim() + try { + await this.watched(repository, remote, args, signal, `${remote}: git fetch`) + } catch (error) { + const ssh = sshUrlOf(remote) + if (ssh === undefined || !(error instanceof DetailFailure) || error.reason !== 'denied') throw error + await this.run(repository, ['config', 'remote.origin.url', ssh], signal) + await this.watched(repository, ssh, args, signal, `${ssh}: git fetch`).catch((sshError: unknown) => { + throw sshError instanceof DetailFailure && sshError.reason !== 'too-large' + ? new DetailFailure(sshError.reason === 'network' ? 'network' : 'denied', `${error.message}\n${sshError.message}`, { cause: sshError }) + : sshError + }) + } await this.evict(repository) } diff --git a/packages/boot/plugin-market/tests/repository-cache.spec.ts b/packages/boot/plugin-market/tests/repository-cache.spec.ts index 9b9e77803c..350e3989fe 100644 --- a/packages/boot/plugin-market/tests/repository-cache.spec.ts +++ b/packages/boot/plugin-market/tests/repository-cache.spec.ts @@ -193,6 +193,52 @@ it('reports a repository Git cannot fetch as denied', async () => { await expect(repositories.read(source.url, source.commit, 'README.md', 100, signal)).resolves.toBeDefined() }) +it('fetches over SSH when Git refuses the https URL, and reports the https refusal when SSH also fails', async () => { + const source = repository('ssh', { 'README.md': 'over ssh', 'a.png': 'png' }) + // The cache allows no https transport, so Git refuses every https URL; the SSH form, without the https port, reaches the source. + writeFileSync(join(root, 'home', '.gitconfig'), [ + `[url "${source.url}"]`, ' insteadOf = ssh://git@git.example/team/plugin.git', + `[url "${pathToFileURL(join(root, 'missing')).href}"]`, ' insteadOf = ssh://git@git.example/team/other.git', '', + ].join('\n')) + const repositories = cache() + expect(text(await repositories.read('https://git.example:8443/team/plugin.git', undefined, 'README.md', 100, signal))).toBe('over ssh') + // The clone keeps the SSH remote for file contents it downloads later. + expect(text(await repositories.read('https://git.example:8443/team/plugin.git', source.commit, 'a.png', 100, signal))).toBe('png') + // A refused SSH fetch reports both refusals. + const refused = repositories.read('https://git.example/team/other.git', undefined, 'README.md', 100, signal) + await expectRejection(refused, { reason: 'denied' }, 'https://git.example/team/other.git: git fetch') + await expect(refused).rejects.toThrow('ssh://git@git.example/team/other.git: git fetch') + // A missing commit on a clone that already fetches over SSH is not retried. + const missing = await repositories.read('https://git.example:8443/team/plugin.git', 'f'.repeat(40), 'README.md', 100, signal).catch((error: unknown) => error) + expect(missing).toMatchObject({ reason: 'denied' }) + expect((missing as Error).message.match(/git fetch/gu)).toEqual(['git fetch']) + expect((missing as Error).message).toContain('ssh://git@git.example/team/plugin.git: git fetch') + // The SSH fetch keeps the repository limit. + const small = cache({ dir: join(root, 'small'), maxRepositoryBytes: 1, sizePollMs: 3_600_000 }) + await expect(small.read('https://git.example/team/plugin.git', undefined, 'README.md', 100, signal)).rejects.toMatchObject({ reason: 'too-large' }) + // A URL with credentials or a query has no SSH form. + for (const url of ['https://user@git.example/team/plugin.git', 'https://git.example/team/plugin.git?x=1']) { + await expectRejection(repositories.read(url, undefined, 'README.md', 100, signal), { reason: 'denied' }, `${url}: git fetch`) + } +}) + +it('reports an SSH retry that cannot connect as a network failure, and does not retry an https URL that cannot connect', async () => { + vi.stubEnv('GIT_SSH_COMMAND', 'ssh -F /dev/null -o BatchMode=yes -o ConnectTimeout=5') + for (const name of ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']) vi.stubEnv(name, '') + writeFileSync(join(root, 'home', '.gitconfig'), [ + '[url "ssh://127.0.0.1:1/down.git"]', ' insteadOf = ssh://git@git.example/team/down.git', + '[url "http://127.0.0.1:1/offline.git"]', ' insteadOf = https://git.example/team/offline.git', + '[http]', ' proxy =', '', + ].join('\n')) + const refused = cache().read('https://git.example/team/down.git', undefined, 'README.md', 100, signal) + await expectRejection(refused, { reason: 'network' }, 'https://git.example/team/down.git: git fetch') + await expect(refused).rejects.toThrow('ssh://git@git.example/team/down.git: git fetch') + const offline = await cache({ transports: ['http', 'ssh'] }).read('https://git.example/team/offline.git', undefined, 'README.md', 100, signal) + .catch((error: unknown) => error) + expect(offline).toMatchObject({ reason: 'network' }) + expect((offline as Error).message).not.toContain('ssh://') +}) + it('stops a fetch once the repository grows past its limit, and deletes the repository', async () => { const source = repository('e', { 'README.md': 'x' }) // The empty repository already exceeds one byte, so the first size check stops Git. diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index c6f661f22d..8c6ebd500c 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0f971f7ac7c09139 zh: ceb866da681c709c /deepseek-ai-dsh-base/use-this-package/what-you-get: - en: 2ef2653d55107eec - zh: 4936c0fff94b1984 + en: 94430c891006526d + zh: 94ba771960ec6f71 /deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform: en: 2dce12f82d6ee955 zh: d4599eb899a3cd88 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index f928d433d6..db77c345ec 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -51,6 +51,8 @@ Out of the box, every profile built on this core provides: a DeepSeek model conn Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. +The bundle also mounts [Agent Teams](../../team/agent-team/README.md) and its task tools. Team mode defaults off per root Session; the root conversation’s **Use Agent Teams** menu switch enables collaboration for that tree without changing the profile. + The built-in `working_directory` tool changes the directory for subsequent operations while existing processes and write permissions remain independent. Current directory information appears in user context. Default file editing uses `read`, `write`, and `edit`. The `str_replace_editor` tool remains available as an explicit opt-in. To add it to a base-backed profile, put this entry in the profile, home, or invocation patch: ```yaml diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 132a40bab3..53c04f4b52 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -51,6 +51,8 @@ kind: "package-bundle" `subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 +此 bundle 还挂载 [Agent Teams](../../team/agent-team/README.zh.md) 及其任务工具。每个根 Session 的 Team 模式默认关闭;根会话菜单中的**使用智能体团队**开关为当前树开启协作,无需修改 profile。 + 内置 `working_directory` 工具改变后续操作使用的目录,已有进程与写权限保持独立。当前目录信息出现在用户上下文中。默认文件编辑使用 `read`、`write` 和 `edit`。`str_replace_editor` 工具仍可显式启用。要将它加入基于 base 的 profile,请在 profile、home 或逐次调用 patch 中添加以下条目: ```yaml diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index fc83f8160c..38a57db211 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -363,6 +363,14 @@ - id: subagent name: '@deepseek-ai/dsh-subagent' + - id: agent-team + name: '@deepseek-ai/dsh-agent-team' + config: + maxTasks: 256 + + - id: tool-agent-team + name: '@deepseek-ai/dsh-tool-agent-team' + - id: subagent-spawn-in-process name: '@deepseek-ai/dsh-subagent-spawn-in-process' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 6c6e508732..68a395af92 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -34,13 +34,16 @@ } }, "dependencies": { - "@deepseek-ai/dsh-hmr": "workspace:*", "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-attachment-local": "workspace:*", - "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-authorization": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-command-compact": "workspace:*", "@deepseek-ai/dsh-command-feedback": "workspace:*", @@ -49,24 +52,29 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:*", "@deepseek-ai/dsh-compaction-image-offload": "workspace:*", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-config-editor": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", + "@deepseek-ai/dsh-deepseek-account-platform": "workspace:*", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:*", "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", - "@deepseek-ai/dsh-api-gateway": "workspace:*", - "@deepseek-ai/dsh-api-remote-access": "workspace:*", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/dsh-jobs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-retry": "workspace:*", "@deepseek-ai/dsh-mcp-resources": "workspace:*", + "@deepseek-ai/dsh-otel": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:*", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", @@ -81,6 +89,8 @@ "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-filesystem": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", @@ -93,44 +103,36 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-jobs-local": "workspace:*", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-agent-team": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", - "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-present": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", - "@deepseek-ai/dsh-tool-jobs": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tool-working-directory": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-loader": "workspace:*", "@deepseek-ai/dsh-typert-registry": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", + "@deepseek-ai/dsh-web-deepseek-api": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", "@deepseek-ai/dsh-web-search-deepseek": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", - "@deepseek-ai/dsh-agent-instructions": "workspace:*", - "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", - "@deepseek-ai/dsh-plugin-manager": "workspace:*", - "@deepseek-ai/dsh-authorization": "workspace:*", - "@deepseek-ai/dsh-deepseek-account-platform": "workspace:*", - "@deepseek-ai/dsh-config-editor": "workspace:*", - "@deepseek-ai/dsh-settings": "workspace:*", - "@deepseek-ai/dsh-otel": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*", - "@deepseek-ai/dsh-tool-working-directory": "workspace:*", - "@deepseek-ai/dsh-web-deepseek-api": "workspace:*" + "@deepseek-ai/dsh-working-directory": "workspace:*" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index d02729c097..bc98d7ae6b 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -426,7 +426,7 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise await agent.whenIdle() const idleSeq = agent.session.seq // Child settlement can wake the parent before the child wait observes it. - if (!await subagents?.waitForChildren(agent) + if (!await subagents?.waitForTreeWork(agent) && agent.status === 'idle' && agent.session.seq === idleSeq) break } } finally { diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 0d00029409..7a76b0b3e5 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -381,7 +381,7 @@ describe('headless runner', () => { if (turns === 1) { await test.ctx.subagents.startActivation({ provider: 'external', label: 'work', delivery: 'parent', signal: new AbortController().signal, - request: { parent, prompt: [] }, + parent, prompt: [], }) await cleaning.promise appendTurn(session, 1, message, 'waiting for child', true) @@ -429,7 +429,7 @@ describe('headless runner', () => { }, }) test.ctx.provide('subagents', { - async waitForChildren(parent: Agent) { + async waitForTreeWork(parent: Agent) { expect(test.ctx.agents.get(parent.id)).toBe(parent) const batch = checks++ if (batch === 2) return false @@ -467,7 +467,7 @@ describe('headless runner', () => { }, }) test.ctx.provide('subagents', { - async waitForChildren(parent: Agent) { + async waitForTreeWork(parent: Agent) { const check = checks++ if (check === 2) return false parent.followup(createUserMessage({ content: [{ type: 'text', text: 'batch complete' }], source: { kind: 'user' } })) diff --git a/packages/bundle/headless/tests/terminal-failures.spec.ts b/packages/bundle/headless/tests/terminal-failures.spec.ts index 5c20881ea9..c92f095334 100644 --- a/packages/bundle/headless/tests/terminal-failures.spec.ts +++ b/packages/bundle/headless/tests/terminal-failures.spec.ts @@ -60,7 +60,7 @@ describe('headless terminal failures', () => { const test = await harness() const failures: unknown[] = [] test.ctx.on('agent/error', ({ error }) => { failures.push(error) }) - const waitForChildren = vi.fn().mockImplementationOnce((parent: Agent) => { + const waitForTreeWork = vi.fn().mockImplementationOnce((parent: Agent) => { const append = parent.session.append.bind(parent.session) const failAppend: typeof parent.session.append = (type, data, ...intent) => { if (type === failedEvent) throw new Error(`summary ${failedEvent} unavailable`) @@ -70,7 +70,7 @@ describe('headless terminal failures', () => { followup(parent) return Promise.resolve(true) }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) const result = await test.run(json) expect(failures).toHaveLength(1) @@ -87,7 +87,7 @@ describe('headless terminal failures', () => { it.each([false, true])('uses the durable outcome after a summary hook fails (recover=%s)', async (recover) => { const test = await harness() - const waitForChildren = vi.fn().mockImplementationOnce((parent: Agent) => { + const waitForTreeWork = vi.fn().mockImplementationOnce((parent: Agent) => { const stop = test.ctx.on('agent/pre-step', () => { stop() throw new Error('summary hook failed') @@ -98,7 +98,7 @@ describe('headless terminal failures', () => { if (recover) followup(parent) return Promise.resolve(recover) }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) await expect(test.run()).resolves.toEqual(recover ? { code: 0, out: 'summary\n', err: '' } @@ -107,7 +107,7 @@ describe('headless terminal failures', () => { it.each(['turn/start', 'turn/end'] as const)('accepts a recovered summary after a missing %s', async (failedEvent) => { const test = await harness() - const waitForChildren = vi.fn().mockImplementationOnce((parent: Agent) => { + const waitForTreeWork = vi.fn().mockImplementationOnce((parent: Agent) => { const append = parent.session.append.bind(parent.session) let failed = false const failAppend: typeof parent.session.append = (type, data, ...intent) => { @@ -124,7 +124,7 @@ describe('headless terminal failures', () => { followup(parent) return Promise.resolve(true) }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) await expect(test.run()).resolves.toEqual({ code: 0, @@ -142,12 +142,12 @@ describe('headless terminal failures', () => { if (agent === other) throw new Error('another Agent failed') return next() }) - const waitForChildren = vi.fn().mockImplementationOnce(async () => { + const waitForTreeWork = vi.fn().mockImplementationOnce(async () => { followup(other) await other.whenIdle() return false }).mockResolvedValue(false) - test.ctx.provide('subagents', { waitForChildren } as never) + test.ctx.provide('subagents', { waitForTreeWork } as never) await expect(test.run()).resolves.toEqual({ code: 0, out: 'waiting\n', err: '' }) }) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index ac78994ab7..adb17d9e6d 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-web-app/use-this-package: - en: 3bd37b85139d2fa7 - zh: ba86c807eb83866e + en: 1f69a4c52a27e7e5 + zh: e4431a252fb86f7c /deepseek-ai-dsh-web-app/use-this-package/starting-the-web-gui: en: e3cac911f080d8ef zh: 60749e9f5c39c1bc diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index cb974443e5..b621d1e8c2 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -31,6 +31,8 @@ Run `dsh --profile web` for browser chat, model and settings management, and ses Start the GUI, open your browser, and start talking to the agent. The flags fine-tune the invocation. +[Agent Teams UI](../../client/ui-agent-team/README.md) loads with this bundle. Enable it per Session in the model and reasoning menu; shared tasks open below the member tree from the Subagent control. + ### Starting the Web GUI ```sh diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 88bbae54a5..9df9111bf3 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -31,6 +31,8 @@ kind: "package-bundle" 启动 GUI、打开浏览器,然后开始与 agent(智能体)对话。flag 用于微调本次调用。 +[Agent Teams UI](../../client/ui-agent-team/README.zh.md) 随本 bundle 加载。在模型与推理等级菜单中按 Session 开启;通过 Subagent 控件打开成员树,下方显示共享任务。 + ### 启动 Web GUI ```sh diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d751fcc462..dfaccf6812 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -382,6 +382,9 @@ - id: ui-skill name: '@deepseek-ai/dsh-client-ui-skill' + - id: ui-agent-team + name: '@deepseek-ai/dsh-client-ui-agent-team' + - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' @@ -552,12 +555,9 @@ - id: tool-result-pruner disabled: true -# The subagent registry and its backends STAY in the host plane. `subagents` is -# a process singleton with a cross-session query surface (`listChildren`, -# `followup`) that the host api-proxy serves to the browser, and a provider -# registers under a globally unique name, so a per-session copy would both -# starve that host row and collide on the second session. What a preset -# chooses is which delegation TOOLS its agent sees, below. +# The subagent registry and its backends belong in the host composition: browser +# prompt and interrupt requests share its runtime, and provider names are unique +# within the registry. Presets select the delegation tools visible to each Agent. - id: tool-subagent-control disabled: true diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 9ecc295724..89cc800d9f 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:*", "@deepseek-ai/dsh-api-job-controller": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", @@ -71,8 +72,11 @@ "@deepseek-ai/dsh-client-hmr": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-modules": "workspace:*", + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:*", + "@deepseek-ai/dsh-client-ui-agent-team": "workspace:*", "@deepseek-ai/dsh-client-ui-approval": "workspace:*", "@deepseek-ai/dsh-client-ui-attachment": "workspace:*", "@deepseek-ai/dsh-client-ui-brand-official": "workspace:*", @@ -97,18 +101,21 @@ "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", "@deepseek-ai/dsh-client-ui-reference": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-secret-mask": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", - "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-shell": "workspace:*", - "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-subagent": "workspace:*", "@deepseek-ai/dsh-client-ui-settings-web-search": "workspace:*", + "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:*", @@ -130,14 +137,17 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", "@deepseek-ai/dsh-cordis-host-runner": "workspace:*", + "@deepseek-ai/dsh-deliverable-file-references": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-file-reference-local": "workspace:*", + "@deepseek-ai/dsh-harness-dev-context": "workspace:*", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:*", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:*", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:*", "@deepseek-ai/dsh-host-frontend-static": "workspace:*", "@deepseek-ai/dsh-host-open-in-app": "workspace:*", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", + "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-host-webserver": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", "@deepseek-ai/dsh-message-feedback": "workspace:*", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 503d65a1c8..1c3494a2f5 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: b6fae58a79112e2b - zh: c95394e541d421ba + en: d7adae6baeed495b + zh: 9e49fb00adc8eec1 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index fe33ee2a20..a61299c2d2 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -64,6 +64,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | | [`ui-secret-mask/`](ui-secret-mask/README.md) | Masks recognized credentials in sent user text | — | +| [`ui-agent-team/`](ui-agent-team/README.md) | Session Team switch and shared tasks below the Subagent tree | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Provides the sidebar Automation tasks page and right Sidebar task tab, which create, edit, run, pause, resume, and delete Host scheduled tasks, plus the Session header clock and row mark | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index de0479c889..1e865a79c2 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -64,6 +64,7 @@ kind: "package-group" | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | | [`ui-secret-mask/`](ui-secret-mask/README.zh.md) | 遮挡已发送用户文本中可识别的凭据 | — | +| [`ui-agent-team/`](ui-agent-team/README.zh.md) | Session Team 开关与 Subagent 树下方的共享任务 | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — | | [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 提供侧栏“自动化任务”页面与右侧栏任务页签,用于创建、编辑、立即运行、暂停、恢复和删除宿主的计划任务,并提供 Session 顶部时钟与行标记 | — | diff --git a/packages/client/locale/src/locales/de.ts b/packages/client/locale/src/locales/de.ts index dd88cf6fc4..cb0bb45579 100644 --- a/packages/client/locale/src/locales/de.ts +++ b/packages/client/locale/src/locales/de.ts @@ -33,6 +33,8 @@ export const de = { 'more': 'Mehr', 'collapse': 'Einklappen', 'expand': 'Ausklappen', + 'subtitle.expand': 'Beschreibung für {name} ausklappen', + 'subtitle.collapse': 'Beschreibung für {name} einklappen', 'back': 'Zurück', 'brand.localBuild': 'Lokaler DSH-Build', 'workspace.defaultName': 'Standardarbeitsbereich', diff --git a/packages/client/locale/src/locales/en.ts b/packages/client/locale/src/locales/en.ts index b649bbc3dc..47a03d69ef 100644 --- a/packages/client/locale/src/locales/en.ts +++ b/packages/client/locale/src/locales/en.ts @@ -32,6 +32,8 @@ export const en = { 'more': 'More', 'collapse': 'Collapse', 'expand': 'Expand', + 'subtitle.expand': 'Expand description for {name}', + 'subtitle.collapse': 'Collapse description for {name}', 'back': 'Back', 'brand.localBuild': 'DSH Local Build', 'workspace.defaultName': 'Default workspace', diff --git a/packages/client/locale/src/locales/es.ts b/packages/client/locale/src/locales/es.ts index 7eb95374e1..4fba984885 100644 --- a/packages/client/locale/src/locales/es.ts +++ b/packages/client/locale/src/locales/es.ts @@ -33,6 +33,8 @@ export const es = { 'more': 'Más', 'collapse': 'Contraer', 'expand': 'Expandir', + 'subtitle.expand': 'Expandir la descripción de {name}', + 'subtitle.collapse': 'Contraer la descripción de {name}', 'back': 'Atrás', 'brand.localBuild': 'Compilación local de DSH', 'workspace.defaultName': 'Espacio de trabajo predeterminado', diff --git a/packages/client/locale/src/locales/fr.ts b/packages/client/locale/src/locales/fr.ts index c57f1a3b11..f0e5030f41 100644 --- a/packages/client/locale/src/locales/fr.ts +++ b/packages/client/locale/src/locales/fr.ts @@ -33,6 +33,8 @@ export const fr = { 'more': 'Plus', 'collapse': 'Réduire', 'expand': 'Développer', + 'subtitle.expand': 'Développer la description de {name}', + 'subtitle.collapse': 'Réduire la description de {name}', 'back': 'Retour', 'brand.localBuild': 'Version locale de DSH', 'workspace.defaultName': 'Espace de travail par défaut', diff --git a/packages/client/locale/src/locales/ja.ts b/packages/client/locale/src/locales/ja.ts index 1186838c31..2b70cbb156 100644 --- a/packages/client/locale/src/locales/ja.ts +++ b/packages/client/locale/src/locales/ja.ts @@ -33,6 +33,8 @@ export const ja = { 'more': 'その他', 'collapse': '折りたたむ', 'expand': '展開', + 'subtitle.expand': '{name} の説明を展開', + 'subtitle.collapse': '{name} の説明を折りたたむ', 'back': '戻る', 'brand.localBuild': 'DSHローカルビルド', 'workspace.defaultName': 'デフォルトワークスペース', diff --git a/packages/client/locale/src/locales/pt.ts b/packages/client/locale/src/locales/pt.ts index 75cdb19c29..adf711152d 100644 --- a/packages/client/locale/src/locales/pt.ts +++ b/packages/client/locale/src/locales/pt.ts @@ -33,6 +33,8 @@ export const pt = { 'more': 'Mais', 'collapse': 'Recolher', 'expand': 'Expandir', + 'subtitle.expand': 'Expandir descrição de {name}', + 'subtitle.collapse': 'Recolher descrição de {name}', 'back': 'Voltar', 'brand.localBuild': 'Versão local do DSH', 'workspace.defaultName': 'Espaço de trabalho padrão', diff --git a/packages/client/locale/src/locales/ru.ts b/packages/client/locale/src/locales/ru.ts index a8a6d829da..e75620fe21 100644 --- a/packages/client/locale/src/locales/ru.ts +++ b/packages/client/locale/src/locales/ru.ts @@ -33,6 +33,8 @@ export const ru = { 'more': 'Ещё', 'collapse': 'Свернуть', 'expand': 'Развернуть', + 'subtitle.expand': 'Развернуть описание {name}', + 'subtitle.collapse': 'Свернуть описание {name}', 'back': 'Назад', 'brand.localBuild': 'Локальная сборка DSH', 'workspace.defaultName': 'Рабочая область по умолчанию', diff --git a/packages/client/locale/src/locales/zh.ts b/packages/client/locale/src/locales/zh.ts index 11ff789b8a..6350caae3c 100644 --- a/packages/client/locale/src/locales/zh.ts +++ b/packages/client/locale/src/locales/zh.ts @@ -30,6 +30,8 @@ export const zh = { 'more': '更多', 'collapse': '收起', 'expand': '展开', + 'subtitle.expand': '展开{name}的描述', + 'subtitle.collapse': '收起{name}的描述', 'back': '返回', 'brand.localBuild': 'DSH 本地构建', 'workspace.defaultName': '默认工作区', diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 8f9a9d597f..86ee490193 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -54,8 +54,8 @@ en: 253d9f265cd83ed0 zh: 9d0f8df84d9d3ac9 /deepseek-ai-dsh-client-modules/understand-the-implementation/entry-ownership: - en: df2994f9ff8872c9 - zh: cd776526ed2b51e6 + en: 608a25c52159629f + zh: c5ef242899cb32c1 /deepseek-ai-dsh-client-modules/understand-the-implementation/source-map: en: d8b5407a0279a3f9 zh: 27af3437fad78672 diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index a27f4271fd..3cf748d9a0 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -92,7 +92,7 @@ The host contributes structured index rows that inject, into ``: the `wind ### Entry ownership -`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available. +`ClientEntries` records the entries created during boot and serializes full-graph updates, retries and code reloads over the same Loader. As the Host Loader group does, a boot entry whose creation rejects, such as a module that exports no plugin, is logged and stays inactive for the boot audit instead of aborting startup. A local generation prevents an older download from mounting after its desired entry or code changes; snapshots of the same targets share the pending load. A graph from another release than the boot release stays outside the queue: retries keep reconciling the last applied graph, and rebuilt frames are ignored while the Host serves that release. New arrivals use single-resource URLs, never startup batches that could register existing factories twice. Factories retain their artifact revision before an entry exists; graph updates discard stale unowned factories, their styles and failed arrival targets before importing consumers. Cleanup retains declared and observed transitive module requests from every remaining Loader entry. Its observable status has no runtime library import because the modules bootstrap materializes before platform seeds are available. ### Source map diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index ab6fe609b3..9016041c36 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -93,7 +93,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册 ### 条目所有权 -`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。 +`ClientEntries` 记录启动时创建的条目,并在同一个 Loader 上串行执行完整图更新、重试和代码重载。与 Host Loader 分组一样,启动时创建失败的条目(例如模块未导出插件)会记录日志并保持未激活,交由启动审计处理,而不会中止启动。本地代际阻止旧下载在目标条目或代码变化后挂载;目标相同的快照共用进行中的加载。来自启动 release 以外 release 的图不进入队列:重试继续调和上次应用的图,Host 提供该 release 期间忽略 rebuilt 帧。新增模块使用单资源 URL,不会重新执行可能重复注册现有 factory 的启动 batch。Factory 在条目创建前就保留产物 revision;图更新会在导入消费者前丢弃未归属条目的陈旧 factory、其样式和失败的到达目标。清理会保留每个剩余 Loader 条目的已声明及已观察到的传递模块依赖。其可观察状态不导入运行时库,因为 modules bootstrap 在平台种子可用之前物化。 ### 源码索引 diff --git a/packages/client/modules/src/client/entries.ts b/packages/client/modules/src/client/entries.ts index e33d0055f8..5da84399ff 100644 --- a/packages/client/modules/src/client/entries.ts +++ b/packages/client/modules/src/client/entries.ts @@ -77,6 +77,8 @@ export class ClientEntries { /** * Create the initial roster and retain its entry identities for subsequent reconciliation. + * As the Host Loader group does, a row whose creation rejects (for example a module that exports no + * plugin) is logged and stays an inactive entry, so one row cannot abort startup before the boot audit. * @param loader - Page Loader, already configured with the module system. * @param manifest - Initial roster audited by the boot caller. * @returns after initial entries and their activation settle; boot owns its activation audit. @@ -92,7 +94,7 @@ export class ClientEntries { }, 'client-modules: entry reconciliation') return this.enqueue(async () => { await Promise.all(this.desired.plugins.map(async ({ id }) => { - await this.create(loader, id) + await this.create(loader, id).catch((error: unknown) => { loader.ctx.logger.error(error) }) })) await loader.await() for (const row of this.modules.manifest.modules) this.revisions.set(row.id, row.rev) diff --git a/packages/client/modules/tests/entries.client.spec.ts b/packages/client/modules/tests/entries.client.spec.ts index dc908bea33..0f56f99b10 100644 --- a/packages/client/modules/tests/entries.client.spec.ts +++ b/packages/client/modules/tests/entries.client.spec.ts @@ -347,6 +347,22 @@ it('retains a rejected Loader entry for retry instead of creating an orphan sibl expect(entry.fiber?.state).toBe(2) }) +it('logs a row whose Loader creation rejects at startup and leaves it inactive for the boot audit', async () => { + let broken = true + const b = await bench(graph(row('a'), row('b')), { a: () => broken ? { default: 'invalid' } : { apply() {} }, b: () => ({ apply() {} }) }, false) + const error = vi.spyOn(b.ctx.logger, 'error').mockImplementation(() => {}) + await b.modules.entries.start(b.ctx.loader, b.modules.manifest) + const entries = new Map([...b.ctx.loader.entries()].map(entry => [entry.options.name, entry])) + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('invalid plugin')) + expect(entries.get('a')?.fiber).toBeUndefined() + expect(entries.get('b')?.fiber?.state).toBe(2) + broken = false + await b.modules.entries.retry() + expect([...b.ctx.loader.entries()]).toHaveLength(2) + expect(entries.get('a')?.fiber?.state).toBe(2) +}) + it('reports Loader import failures and recovers the same entry after the importer recovers', async () => { const b = await bench(graph(), { a: () => ({ apply() {} }) }) let broken = true diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index f828de9af1..50a1b2b181 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -19,7 +19,6 @@ .icon { flex: none; - opacity: 0.7; } /* First sacrifice when the title row (the enclosing inline-size container) diff --git a/packages/client/ui-agent-team/README.i18n.yaml b/packages/client/ui-agent-team/README.i18n.yaml new file mode 100644 index 0000000000..dfab72bf26 --- /dev/null +++ b/packages/client/ui-agent-team/README.i18n.yaml @@ -0,0 +1,40 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-agent-team/README.md +/: + en: cecfe90c9079279f + zh: 58062db454093088 +/deepseek-ai-dsh-client-ui-agent-team: + en: 39c47e6e926425bf + zh: 0fd155c454355e3a +/deepseek-ai-dsh-client-ui-agent-team/summary: + en: 88103cec4c91d755 + zh: 6536e4307a5ce122 +/deepseek-ai-dsh-client-ui-agent-team/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-client-ui-agent-team/use-this-package: + en: d502ab38773eef34 + zh: e1f9c4edbc74c38f +/deepseek-ai-dsh-client-ui-agent-team/use-this-package/inspect-the-task-board: + en: 7e88fb00a3abd230 + zh: 08efcf80afecb0c0 +/deepseek-ai-dsh-client-ui-agent-team/understand-the-implementation: + en: 9b0b6a8505a9ebbf + zh: beb5d89f80463b3e +/deepseek-ai-dsh-client-ui-agent-team/further-exploration: + en: 4d9214370681b02e + zh: 4acb21b9ecf25e85 +/deepseek-ai-dsh-client-ui-agent-team/model-experience: + en: 47ebeba07d72759d + zh: 8fe8133f6dd845a9 +/deepseek-ai-dsh-client-ui-agent-team/model-experience/kv-cache-effect: + en: b51b320f5e4dacfc + zh: 6695947c3f9fed28 +/deepseek-ai-dsh-client-ui-agent-team/known-limitations-and-deferred-work: + en: 8be0a045f2f42e9e + zh: 36f98d5cc1189f7a +/deepseek-ai-dsh-client-ui-agent-team/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-agent-team/README.md b/packages/client/ui-agent-team/README.md new file mode 100644 index 0000000000..d156e7b7cb --- /dev/null +++ b/packages/client/ui-agent-team/README.md @@ -0,0 +1,100 @@ +--- +description: "Inspect the shared task board from the Web conversation header." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-agent-team + +English | [中文](README.zh.md) + +## Summary + +This package displays shared tasks below the Subagent tree in one header panel. Host projection frames keep task state current without a refresh control. Ordinary subagent navigation owns the member list and teammate conversations. The Web bundle loads this package; it adds no model-facing input. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In the root conversation’s model and reasoning menu, turn on **Enable agent teamwork**. New Sessions default to off. While enabled, open the root conversation’s Subagent control to see members above shared tasks. Child conversations keep subagent navigation without a task board or Team switch, including before their subagent address is discovered. The entry is hidden when both the subagent catalog and shared tasks are empty; catalog and task-record errors remain accessible. Section headers appear only when Team mode is enabled, at least one nondeleted shared task exists, and the task footer is available. In that case, the noncollapsible Subagents section remains above shared tasks even when its catalog is empty. Otherwise, the menu shows only subagent navigation, without section headers, a shared-task module, empty-state copy, or a divider. Turning it off retains tasks and subagents. The entire Team row highlights on hover and toggles the selection when clicked, except for its independent information button. The switch calls `remote.agentTeams.setEnabled`, waits for the saved projection and reports failures through the menu host; the row cannot toggle while locked or saving. The adjacent information button explains shared-task coordination and additional token use on hover, keyboard focus, or click/tap without changing the selection. Escape or an outside press dismisses the explanation. + +Turning off the menu switch requires confirmation after the Session records a model request; a model choice alone does not require it. The switch waits while request history is unavailable. The dialog explains the tool, coordination, and possible cache effects without deleting task records. Cancel, Escape, or closing the dialog leaves Team mode unchanged. Dismissing the dialog returns focus to the model selector. Confirmation targets the originating Session, waits for the save, and keeps failures visible for retry; it does not depend on the model menu remaining open. Team mode has no slash command; menu changes create no `command/run` or `command/done` records. + +Chat hides historical `/team on` and `/team off` receipts with pending or successful outcomes. Failures remain visible with a Users icon, a localized “Agent teamwork” title, and a localized failure prefix followed by the first nonempty line of the recorded reason without translation. Expanding the receipt shows the full original error. Unknown or missing arguments retain a visible receipt. + +The [Web bundle](../../bundle/web-app/README.md) loads this package by default. The Web Client loader mounts the `/client` export; the root Host export is inert, and the package has no user configuration fields. + +### Inspect the task board + +Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. + +The noncollapsible Shared tasks header uses a Checklist icon and completed, in-progress, and pending counts, with zero counts omitted and wording and separators matching the input task list. Compact read-only rows show the subject, status, and a chevron, with 13px primary titles and hover geometry aligned to subagent rows; expanded details use 12px text. Task rows use 7px vertical padding, 6px horizontal padding, and 7px gaps to align their clickable content with subagent rows. All task details start closed; opening one closes the previous task. Details show only the full description when present, followed by a single-line owner label and Session id in the same color with a 4px gap. Long owner ids are ellipsized and reveal their full text in a tooltip only when clipped. Task-text and owner tooltips appear after 500ms of hover. Every owner is identified by its Session id. The shared menu is 360px wide with 6px outer padding, constrained to fit the viewport. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; task lists and details have no nested scrolling. Agents mutate tasks through tools; the panel provides no mutation controls. A projection failure appears separately above any retained tasks and remains visible when no tasks exist. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Client export registers its locale dictionaries and subagent-catalog and model-menu slots through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes its registrations. + +The Subagent catalog owns the shared panel, scrolling, hover, keyboard, and dismissal interactions. This package contributes the task section only below the root conversation’s tree. The board reads the current Session’s mode and tasks through `useProjection('agentTeam')`. An available, enabled projection supplies the task section only when it contains nondeleted tasks; a projection failure can render independently without a task section. Task IDs, dependencies, readiness, write scopes, and write scope warnings remain in task data but are not displayed as detail fields; blocked pending tasks still use the warning glyph. + +The [Chat visibility policy](../ui-chat/src/client/contract/chat-visibility.ts) hides only historical commands named `team` whose trimmed recorded arguments are exactly `on` or `off` and whose outcome is pending or successful. The remaining receipts use a localized running summary or preserve raw successful output, with a localized completed fallback when that text is empty. Rendering neither reads the current Team projection nor parses English outcome text. Filtering and localization affect only Chat presentation: command records, logs, trajectory, and raw errors remain unchanged. + +| File | Role | +|---|---| +| [`src/client/mount.ts`](src/client/mount.ts) | Locale, catalog-section, and model-menu registrations | +| [`src/client/TeamBoard.tsx`](src/client/TeamBoard.tsx) | Projection-derived task section | +| [`src/client/locales.ts`](src/client/locales.ts) | Panel and command copy in eight languages | +| [`src/index.ts`](src/index.ts) | Inert Host entry | + +
+ +----- + + +## Further Exploration + +- [Web bundle](../../bundle/web-app/README.md) — mounts the Client plugin by default. +- [Agent Teams service](../../team/agent-team/README.md) — authoritative task and projection behavior. +- [Conversation UI](../ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. + +----- + + +## Model Experience + +None, as this browser projection 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 + + + +- **No mailbox timeline** — the panel displays tasks, not messages. +- **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/ui-agent-team/README.zh.md b/packages/client/ui-agent-team/README.zh.md new file mode 100644 index 0000000000..6a370c6eeb --- /dev/null +++ b/packages/client/ui-agent-team/README.zh.md @@ -0,0 +1,100 @@ +--- +description: "从 Web 对话标题栏查看共享任务板。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-ui-agent-team + +[English](README.md) | 中文 + +## 概述 + +此包在同一个页头面板中,将共享任务显示在 Subagent 树下方。Host 投影帧持续更新任务状态,无需刷新控件。普通 subagent(子智能体)导航负责成员列表和队友对话。Web bundle 默认加载此包;它不添加模型可见输入。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在根会话的模型与推理等级菜单中打开**启用智能体团队协作**。新 Session 默认关闭。开启后,通过根会话的 Subagent 入口同时查看上方的成员树和下方的共享任务。子会话保留子智能体导航,不显示任务板或 Team 开关,包括尚未发现子代理地址时。子智能体目录和共享任务都为空时隐藏入口;目录或任务记录错误仍保留查看入口。仅当 Team 模式已开启、存在至少一个未删除的共享任务且任务 footer 可用时,才显示区域标题。此时即使目录为空,共享任务上方仍保留不可折叠的“子智能体”区域。其他情况下,菜单仅显示 subagent 导航,不显示区域标题、共享任务模块、空状态文案或分隔线。关闭后保留任务和子代理。团队设置整行在悬停时高亮,点击可切换状态,但独立的信息按钮除外。开关调用 `remote.agentTeams.setEnabled`,等待保存后的投影更新,失败由菜单宿主提示;锁定或保存中不能通过整行切换。旁边的信息按钮可通过悬停、键盘聚焦或点击/轻触查看共享任务协作与额外 token 消耗的说明,不改变开关状态。按 Escape 或点击外部可关闭说明。 + +会话记录过模型请求后,从菜单开关停用团队需要确认;仅选择模型不会触发确认。请求历史尚未加载时,开关暂不可操作。弹窗说明工具、协作与可能的缓存影响,不会删除任务记录。取消、按 Escape 或关闭弹窗都保持团队状态不变。弹窗关闭后,焦点回到模型选择按钮。确认操作绑定到最初的会话,等待保存完成,失败时保留错误供重试,不依赖模型菜单保持打开。Team 模式没有斜杠命令;菜单切换不会生成 `command/run` 或 `command/done` 记录。 + +Chat 隐藏历史 `/team on` 和 `/team off` 记录中尚未完成或执行成功的回执。失败回执仍显示 Users 图标、本地化的“智能体团队协作”标题,以及本地化的失败前缀和已记录原因的首个非空行,不翻译原因原文。展开回执可查看完整原始错误。参数无法识别或缺失时仍显示回执。 + +[Web bundle](../../bundle/web-app/README.zh.md) 默认加载本包。Web Client loader 挂载 `/client` export;root Host export 不执行行为,本包也没有用户配置字段。 + +### 查看任务板 + +可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 + +不可折叠的“共享任务”标题使用 Checklist 图标,并显示已完成、进行中和待处理数量;省略零值,文案和分隔符与输入区任务列表一致。紧凑只读行显示主题、状态和展开箭头,13px 主标题与悬停区域对齐子智能体行;展开详情使用 12px 文字。任务行使用 7px 垂直内边距、6px 水平内边距和 7px 间距,使可点击内容与 subagent 行对齐。所有任务详情初始关闭,打开一项会关闭上一项。详情仅显示完整描述(如有),下方以单行显示负责人标签和 Session id,两者使用相同颜色,间距为 4px。过长的负责人 id以省略号截断,仅在被截断时通过工具提示显示完整内容。任务文字和负责人的工具提示在悬停 500ms 后显示。每个负责人均通过其 Session id 标识。共享菜单宽度为 360px,外层内边距为 6px,并受视口可用宽度约束。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,任务列表与详情不单独滚动。Agent 通过工具修改任务;面板不提供修改控件。投影失败以独立警告显示在保留的任务上方,即使没有任务也保持可见。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Client export 通过 Cordis effect 注册语言字典、子代理目录与模型菜单 slot;它不挂载 Remote namespace。销毁插件 fiber 会移除这些注册。 + +Subagent 目录负责共享面板的滚动、悬停、键盘和关闭交互。本包仅在根会话的树下方提供任务区。任务板通过 `useProjection('agentTeam')` 读取当前 Session 的模式与任务;仅当投影可用、已开启且含有未删除的任务时才显示任务区;投影错误可独立显示,不需要任务区。任务 ID、依赖、准备状态、写入范围及其提示仍保留在任务数据中,但不作为详情字段显示;被依赖阻塞的 pending 任务仍使用 warning 图标。 + +[Chat 可见性策略](../ui-chat/src/client/contract/chat-visibility.ts) 仅隐藏名称为 `team`、已记录参数去除首尾空白后恰为 `on` 或 `off`,且尚未完成或执行成功的历史命令。其余回执在执行中显示本地化摘要,成功时保留原始结果文本,文本为空则使用本地化的完成提示。渲染既不读取当前 Team 投影,也不解析英文结果文案。过滤与本地化仅影响 Chat 展示:命令记录、日志、轨迹和原始错误均保持不变。 + +| 文件 | 职责 | +|---|---| +| [`src/client/mount.ts`](src/client/mount.ts) | 语言字典、目录任务区和模型菜单注册 | +| [`src/client/TeamBoard.tsx`](src/client/TeamBoard.tsx) | 从投影派生的任务区 | +| [`src/client/locales.ts`](src/client/locales.ts) | 八种语言的面板与命令文案 | +| [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry | + +
+ +----- + + +## 进一步探索 + +- [Web bundle](../../bundle/web-app/README.zh.md)——默认挂载本 Client plugin。 +- [Agent Teams 服务](../../team/agent-team/README.zh.md) — 任务和投影行为的权威来源。 +- [会话 UI](../ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 + +----- + + +## 模型体验 + +无直接影响,因为该浏览器 projection 不注册面向模型的输入。 + +#### KV Cache 影响 + +无直接影响;Team 工具与普通会话提交负责后续任何模型可见用途。 + +## 已知限制与延期工作 + + + +- **无邮箱时间线** — 面板显示任务,不显示消息。 +- **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-agent-team/locale/de.json b/packages/client/ui-agent-team/locale/de.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/de.json rename to packages/client/ui-agent-team/locale/de.json diff --git a/packages/client/ui-agent-team/locale/en.json b/packages/client/ui-agent-team/locale/en.json new file mode 100644 index 0000000000..c6667a6afb --- /dev/null +++ b/packages/client/ui-agent-team/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Task board", + "description": "View shared tasks for the subagent tree." + } +} diff --git a/packages/experimental/client-ui-agent-team/locale/es.json b/packages/client/ui-agent-team/locale/es.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/es.json rename to packages/client/ui-agent-team/locale/es.json diff --git a/packages/experimental/client-ui-agent-team/locale/fr.json b/packages/client/ui-agent-team/locale/fr.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/fr.json rename to packages/client/ui-agent-team/locale/fr.json diff --git a/packages/client/ui-agent-team/locale/ja.json b/packages/client/ui-agent-team/locale/ja.json new file mode 100644 index 0000000000..ae63e83f31 --- /dev/null +++ b/packages/client/ui-agent-team/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "タスクボード", + "description": "サブエージェントツリーの共有タスクを表示します。" + } +} diff --git a/packages/experimental/client-ui-agent-team/locale/pt.json b/packages/client/ui-agent-team/locale/pt.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/pt.json rename to packages/client/ui-agent-team/locale/pt.json diff --git a/packages/experimental/client-ui-agent-team/locale/ru.json b/packages/client/ui-agent-team/locale/ru.json similarity index 100% rename from packages/experimental/client-ui-agent-team/locale/ru.json rename to packages/client/ui-agent-team/locale/ru.json diff --git a/packages/client/ui-agent-team/locale/zh.json b/packages/client/ui-agent-team/locale/zh.json new file mode 100644 index 0000000000..9d06145f0f --- /dev/null +++ b/packages/client/ui-agent-team/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "任务板", + "description": "查看子智能体树的共享任务。" + } +} diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/client/ui-agent-team/package.json similarity index 71% rename from packages/experimental/client-ui-agent-team/package.json rename to packages/client/ui-agent-team/package.json index 17963f6de8..271137193a 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/client/ui-agent-team/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", - "description": "Web Agent Teams roster, task board, and teammate navigation", + "name": "@deepseek-ai/dsh-client-ui-agent-team", + "description": "Web shared task board for a subagent tree", "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/experimental/client-ui-agent-team" + "directory": "packages/client/ui-agent-team" }, "type": "module", "main": "lib/index.js", @@ -32,8 +32,9 @@ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives", - "@deepseek-ai/dsh-client-ui-workspace" + "@deepseek-ai/dsh-client-ui-chat", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-primitives" ], "platform": "web" } @@ -51,18 +52,23 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", - "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.3.3", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", + "@deepseek-ai/dsh-client-ui-model-selection": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-ui-subagent": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*" }, "files": [ diff --git a/packages/client/ui-agent-team/src/client/TeamBoard.module.css b/packages/client/ui-agent-team/src/client/TeamBoard.module.css new file mode 100644 index 0000000000..3d10436a16 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamBoard.module.css @@ -0,0 +1,165 @@ +.board { + min-width: 0; + padding: 8px 7px; +} + +.board[data-with-members='true'] { + margin-top: 4px; +} + +.header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 7px; + margin: 0 0 4px; + padding: 0; +} + +.lead, +.glyph, +.chevron { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-weight: 500; + line-height: 24px; +} + +.progress { + margin-inline-start: 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + font-weight: 400; + font-variant-numeric: tabular-nums; + line-height: 20px; + overflow-wrap: anywhere; +} + +.tasks { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.task { + min-width: 0; +} + +.taskRow { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 7px; + width: calc(100% + 12px); + min-width: 0; + margin: 0 -6px; + padding: 7px 6px; + border: 0; + border-radius: var(--dsw-radius-md); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + line-height: 17px; + text-align: left; + cursor: pointer; +} + +.taskRow:focus-visible { + outline: none; + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .taskRow:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.glyph { + width: 14px; + height: 17px; +} + +.subject { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task[data-expanded='true'] .taskRow { + align-items: flex-start; +} + +.task[data-expanded='true'] .subject { + overflow: visible; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.task[data-expanded='true'] .chevron { + transform: rotate(180deg); +} + +.chevron { + width: 28px; + height: 17px; +} + +.details { + padding: 0 0 8px 21px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.description { + margin: 0; + white-space: pre-wrap; +} + +.owner { + display: flex; + gap: 4px; + min-width: 0; + margin: 0; + white-space: nowrap; +} + +.description + .owner { + margin-top: 6px; +} + +.ownerLabel { + flex: none; +} + +.ownerValue { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.error { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 8px 0; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-agent-team/src/client/TeamBoard.tsx b/packages/client/ui-agent-team/src/client/TeamBoard.tsx new file mode 100644 index 0000000000..dbc554d33c --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamBoard.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TeamTaskView, TeamTaskId } from '@deepseek-ai/dsh-agent-team/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import { + IconChecklistOutlineRegular, IconChevronDownOutlineRegular, + StateDot, Tooltip, type StateDotState, +} 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-subagent/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { NS, type TeamKey } from './locales.ts' +import css from './TeamBoard.module.css' + +/** Full props of the task board below the shared subagent catalog. */ +export type TeamBoardProps = + PropsRuntime<'conversation.session.header.actions.subagent.footer'> & PropsLocale + +type VisibleTask = TeamTaskView & { status: Exclude } + +const statusKeys = { + pending: 'status.pending', in_progress: 'status.in_progress', completed: 'status.completed', +} satisfies Record + +const dotStates = { + pending: 'idle', in_progress: 'ongoing', completed: 'done', +} satisfies Record + +function progressLabel(tasks: readonly VisibleTask[], t: TeamBoardProps['t']): string { + const completed = tasks.filter(task => task.status === 'completed').length + const active = tasks.filter(task => task.status === 'in_progress').length + const pending = tasks.length - completed - active + return [ + ...completed > 0 ? [t('progress.completed', { count: completed })] : [], + ...active > 0 ? [t('progress.active', { count: active })] : [], + ...pending > 0 ? [t('progress.pending', { count: pending })] : [], + ].join('\u2002·\u2002') +} + +function TaskRow({ task, rootId, expanded, onToggle, t }: Pick & { + task: VisibleTask + rootId: SessionId + expanded: boolean + onToggle: () => void +}) { + const owner = task.ownerId ?? t('unowned') + const blocked = task.status === 'pending' && !task.ready + const detailsId = `team-task-${rootId}-${task.id}` + return ( +
  • + + + + {expanded && ( +
    + {task.description &&

    {task.description}

    } +

    + {t('owner')}: + + {owner} + +

    +
    + )} +
  • + ) +} + +/** Render read-only tasks in source order; the enclosing subagent menu owns scrolling. */ +export function TeamBoard({ sessionId, useProjection, useSessions, t }: TeamBoardProps) { + const team = useProjection('agentTeam') + const hasMembers = useSessions(state => (state.projectionsBySession[sessionId]?.values.subagentCatalog?.length ?? 0) > 0) + const [expandedTaskId, setExpandedTaskId] = useState(null) + if (!team?.enabled) return null + const tasks = team.tasks.filter((task): task is VisibleTask => task.status !== 'deleted') + const failure = team.failure === undefined ? null : ( +
    {t('failure', { message: team.failure })}
    + ) + if (tasks.length === 0) return failure === null ? null :
    {failure}
    + return ( +
    +

    + + {t('tasks')} + {progressLabel(tasks, t)} +

    + {failure} +
      + {tasks.map(task => ( + { setExpandedTaskId(current => current === task.id ? null : task.id) }} + t={t} + /> + ))} +
    +
    + ) +} diff --git a/packages/client/ui-agent-team/src/client/TeamCommandRow.module.css b/packages/client/ui-agent-team/src/client/TeamCommandRow.module.css new file mode 100644 index 0000000000..a36f5ff903 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamCommandRow.module.css @@ -0,0 +1,44 @@ +.root { + display: flex; + flex-direction: column; +} + +.title { font-weight: 400; } + +.separator { + flex: none; + width: 2px; + height: 2px; + margin: 0 8px; + border-radius: 1px; + background: var(--dsw-alias-label-caption); +} + +.summary { + min-width: 0; + overflow: hidden; + flex: 1 1 auto; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); + text-overflow: ellipsis; + white-space: nowrap; +} + +.summary[data-error], +.body[data-error] { + color: var(--dsw-alias-state-error-primary); +} + +.body { + max-height: 260px; + margin: 4px 0 4px 4px; + padding: 12px 16px; + overflow: auto; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-lg); + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-markdown-code-block-small); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-agent-team/src/client/TeamCommandRow.tsx b/packages/client/ui-agent-team/src/client/TeamCommandRow.tsx new file mode 100644 index 0000000000..dde0b5cced --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamCommandRow.tsx @@ -0,0 +1,65 @@ +/** Read-only historical /team failures and fallback output. */ +import { useState } from 'react' +import { DisclosureRow, IconUsersOutlineRegular, TextShimmer } 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-chat/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { NS } from './locales.ts' +import css from './TeamCommandRow.module.css' + +/** Framework command data and the owning Team dictionary. */ +export type TeamCommandRowProps = PropsRuntime<'conversation.chat.commandview'> & PropsLocale + +const TEAM_ICON = + +function commandSummary(node: TeamCommandRowProps['node'], t: TeamCommandRowProps['t']): string { + const outcome = node.outcome + if (outcome === null) return t('command.running') + switch (outcome.kind) { + case 'success': + return outcome.text || t('command.done') + case 'error': { + const reason = outcome.text?.trim().split(/\r?\n/u)[0] + return reason ? t('command.failedReason', { reason }) : t('command.failed') + } + /* v8 ignore next -- CommandNode restricts outcomes to success or error. */ + default: return assertNever(outcome.kind) + } +} + +/** + * Render the recorded Team command with the shared agent glyph and disclosure. + * @param props - command record and localized Team text. + * @returns an outcome row; recorded failures and multiline output remain expandable. + */ +export function TeamCommandRow({ node, t }: Pick) { + const [expanded, setExpanded] = useState(false) + const running = node.outcome === null + const failed = node.outcome?.kind === 'error' + const summary = commandSummary(node, t) + const body = node.outcome?.text + const expandable = body !== undefined && body !== '' && (failed || body.includes('\n')) + return ( +
    + { setExpanded(value => !value) }} + collapsedContent={( + <> + + {summary} + + )} + > +
    {body}
    +
    +
    + ) +} diff --git a/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.module.css b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.module.css new file mode 100644 index 0000000000..5763edbcbc --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.module.css @@ -0,0 +1,31 @@ +.dialog { + width: min(440px, 100%); + max-height: 100%; +} + +.content { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.copy { + min-width: 0; + overflow-wrap: anywhere; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 22px; +} + +.copy p { + margin: 0; +} + +.copy p + p { + margin-top: 12px; +} + +.error { + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.tsx b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.tsx new file mode 100644 index 0000000000..e02bc87ba3 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamDisableConfirmation.tsx @@ -0,0 +1,76 @@ +/** Session-targeted confirmation that outlives the model menu which opened it. */ +import { useState } from 'react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { NS } from './locales.ts' +import css from './TeamDisableConfirmation.module.css' + +/** The originating Session remains the target while the dialog is open. */ +export interface TeamDisableRequest { + sessionId: SessionId +} + +/** Confirmation request and Session-bound selection callbacks supplied by the plugin. */ +export interface TeamDisableConfirmationInjected { + hooks: { disableRequest: HostObservable } + cancelDisable: (sessionId: SessionId) => void + disableTeam: (sessionId: SessionId) => Promise +} + +/** Root overlay props, independent of the model menu lifetime. */ +export type TeamDisableConfirmationProps = PropsRuntime<'shell.overlay'> + & InjectFace & PropsLocale + +/** + * Render the requested confirmation outside the model menu. + * @param props - request observation, command callbacks, and localized text. + * @returns the confirmation, or nothing when no request is pending. + */ +export function TeamDisableConfirmation({ useDisableRequest, ...props }: TeamDisableConfirmationProps) { + const request = useDisableRequest(value => value) + if (request === null) return null + return +} + +/** One confirmation keeps its failure visible until retry or cancellation. */ +function DisableConfirmationForm({ sessionId, cancelDisable, disableTeam, t }: { + sessionId: SessionId + cancelDisable: TeamDisableConfirmationInjected['cancelDisable'] + disableTeam: TeamDisableConfirmationInjected['disableTeam'] + t: TeamDisableConfirmationProps['t'] +}) { + const [state, setState] = useState<{ kind: 'ready' } | { kind: 'saving' } | { kind: 'failed'; message: string }>({ kind: 'ready' }) + const saving = state.kind === 'saving' + const cancel = (): void => { + if (!saving) cancelDisable(sessionId) + } + const confirm = (): void => { + if (saving) return + setState({ kind: 'saving' }) + void disableTeam(sessionId).then((failure) => { + if (failure === null) cancelDisable(sessionId) + else setState({ kind: 'failed', message: failure }) + }, (error: unknown) => { + setState({ kind: 'failed', message: error instanceof Error ? error.message : String(error) }) + }) + } + return ( + + + + + )} + > +
    +

    {t('disable.description')}

    +

    {t('disable.warning')}

    +
    + {state.kind === 'failed' &&

    {t('mode.failed', { message: state.message })}

    } +
    + ) +} diff --git a/packages/client/ui-agent-team/src/client/TeamModeToggle.module.css b/packages/client/ui-agent-team/src/client/TeamModeToggle.module.css new file mode 100644 index 0000000000..75eedfb3e7 --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamModeToggle.module.css @@ -0,0 +1,52 @@ +.row { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 34px; + padding: 0 8px; + border-radius: var(--dsw-radius-md); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; +} + +.label { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.infoButton { + position: relative; + z-index: 1; + flex: none; + width: 20px; + height: 20px; + padding: 0; + color: var(--dsw-alias-label-caption); +} + +/* Extend the native switch hit target without nesting the information button. */ +.row .toggle { + position: static; +} + +.toggle::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--dsw-radius-md); +} + +.row:has(.toggle:focus-visible) { + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .row:has(.toggle:not(:disabled)):hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} diff --git a/packages/client/ui-agent-team/src/client/TeamModeToggle.tsx b/packages/client/ui-agent-team/src/client/TeamModeToggle.tsx new file mode 100644 index 0000000000..cd0d3dcafa --- /dev/null +++ b/packages/client/ui-agent-team/src/client/TeamModeToggle.tsx @@ -0,0 +1,70 @@ +/** Session-owned Agent Teams selection in the model and reasoning menu. */ + +import { useState } from 'react' +import { Button, IconInfoOutlineRegular, Switch, Tooltip } 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-model-selection/client' +import type {} from '@deepseek-ai/dsh-agent-team/client' +import type {} from '@deepseek-ai/dsh-agent' +import type { NS } from './locales.ts' +import css from './TeamModeToggle.module.css' + +/** Session selection callbacks supplied by the Client plugin. */ +export interface TeamModeToggleInjected { + /** Save the selection; null means success, otherwise the string describes the failure. */ + selectTeamMode: (enabled: boolean) => Promise + /** Open a Session-targeted confirmation outside this menu. */ + requestDisableConfirmation: () => void +} + +/** Full props of the model-menu Team switch. */ +export type TeamModeToggleProps = PropsRuntime<'conversation.input.model.options'> + & PropsLocale & TeamModeToggleInjected + +/** + * Render the saved selection without an optimistic update. + * @param props - Session projection, menu state, localized copy, and selection callback. + * @returns the Team switch when the capability projection is available. + */ +export function TeamModeToggle(props: TeamModeToggleProps) { + const addressedChild = props.useSession(snapshot => snapshot.subagent !== null) + const listedChild = props.useSessions(state => state.byId[props.sessionId]?.origin === 'subagent') + if (addressedChild || listedChild) return null + return +} + +/** Read and change the selection only for root Sessions. */ +function RootTeamModeToggle({ + useProjection, locked, dismiss, reportFailure, selectTeamMode, requestDisableConfirmation, t, +}: TeamModeToggleProps) { + const mode = useProjection('agentTeam') + const modelSelection = useProjection('modelSelection') + const [pending, setPending] = useState(false) + if (mode === undefined) return null + const select = (enabled: boolean): void => { + if (!enabled && modelSelection !== undefined && modelSelection.lastUsed !== null) { + dismiss() + requestDisableConfirmation() + return + } + setPending(true) + void selectTeamMode(enabled).then((failure) => { + if (failure !== null) reportFailure(t('mode.failed', { message: failure })) + }, (error: unknown) => { + reportFailure(t('mode.failed', { message: error instanceof Error ? error.message : String(error) })) + }).finally(() => { setPending(false) }) + } + return ( +
    + + {t('mode.label')} + + + + + +
    + ) +} diff --git a/packages/experimental/client-ui-agent-team/src/client/index.ts b/packages/client/ui-agent-team/src/client/index.ts similarity index 60% rename from packages/experimental/client-ui-agent-team/src/client/index.ts rename to packages/client/ui-agent-team/src/client/index.ts index a4896c82af..23de9e7ee5 100644 --- a/packages/experimental/client-ui-agent-team/src/client/index.ts +++ b/packages/client/ui-agent-team/src/client/index.ts @@ -1,14 +1,14 @@ -/** Browser entry registering the Agent Teams conversation-header action. */ +/** Browser entry registering the task-board sections in the subagent catalog. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { registerAgentTeamUi } from './mount.ts' export { inject } from './mount.ts' -export type { TeamActionInjected, TeamActionProps } from './TeamAction.tsx' +export type { TeamBoardProps } from './TeamBoard.tsx' export type { TeamKey } from './locales.ts' /** - * Register the Team locale dictionaries and header action on the Client Context. + * Register the task-board locale dictionaries and catalog sections on the Client Context. * @param ctx - Client Context with the declared `inject` services available. */ export function apply(ctx: ClientContext): void { diff --git a/packages/client/ui-agent-team/src/client/locales.ts b/packages/client/ui-agent-team/src/client/locales.ts new file mode 100644 index 0000000000..0c5b29a4fb --- /dev/null +++ b/packages/client/ui-agent-team/src/client/locales.ts @@ -0,0 +1,279 @@ +/** Agent teamwork Web dictionaries. */ + +/** Locale namespace owned by the Agent teamwork Web UI. */ +export const NS = 'agent-team' + +/** Simplified Chinese dictionary and key source. */ +export const zh = { + 'progress.completed': '{count} 已完成', + 'progress.active': '{count} 进行中', + 'progress.pending': '{count} 待处理', + 'command.title': '智能体团队协作', + 'command.running': '执行中', + 'command.done': '已完成', + 'command.failed': '执行失败', + 'command.failedReason': '执行失败:{reason}', + trigger: '任务板', + 'mode.label': '启用智能体团队协作', + 'mode.infoLabel': '关于智能体团队协作', + 'mode.description': '通过共享任务板协调多个智能体的分工与进度,复杂任务表现更佳,token 开销也相应更高。', + 'mode.failed': '无法确认智能体团队协作设置已保存,请重试:{message}', + 'disable.title': '确认停用智能体团队协作?', + 'disable.description': '对话过程中停用智能体团队协作,将停止共享任务工具和团队协调指令。', + 'disable.warning': '停用后可能影响缓存复用、增加后续 token 用量与开销,并影响当前任务的协作效果。', + 'disable.cancel': '取消', + 'disable.confirm': '确认停用', + 'disable.close': '关闭', + loading: '正在加载任务板…', + unavailable: '任务板暂不可用', + failure: '任务记录无效:{message}', + empty: '暂无共享任务,可以通过对话创建', + tasks: '共享任务', + owner: '负责人', + unowned: '未分配', + blocked: '被依赖阻塞', + 'status.pending': '待处理', + 'status.in_progress': '进行中', + 'status.completed': '已完成', +} satisfies Record + +/** Agent teamwork locale key union. */ +export type TeamKey = keyof typeof zh + +/** English dictionary checked against the Chinese key set. */ +export const en = { + 'progress.completed': '{count} completed', + 'progress.active': '{count} in progress', + 'progress.pending': '{count} pending', + 'command.title': 'Agent teamwork', + 'command.running': 'Running', + 'command.done': 'Completed', + 'command.failed': 'Failed', + 'command.failedReason': 'Failed: {reason}', + trigger: 'Task board', + 'mode.label': 'Enable agent teamwork', + 'mode.infoLabel': 'About agent teamwork', + 'mode.description': 'Coordinate work and progress across agents with a shared task board for better results on complex tasks, with correspondingly higher token costs.', + 'mode.failed': 'Could not confirm the agent teamwork setting was saved. Try again: {message}', + 'disable.title': 'Disable agent teamwork?', + 'disable.description': 'Disabling agent teamwork during a conversation stops the use of shared task tools and team coordination instructions.', + 'disable.warning': 'Disabling it may affect cache reuse, increase subsequent token usage and costs, and affect collaboration on the current task.', + 'disable.cancel': 'Cancel', + 'disable.confirm': 'Disable', + 'disable.close': 'Close', + loading: 'Loading task board…', + unavailable: 'Task board is unavailable', + failure: 'Invalid persisted task record: {message}', + empty: 'No shared tasks yet. Create them through the conversation.', + tasks: 'Shared tasks', + owner: 'Owner', + unowned: 'Unowned', + blocked: 'Blocked by dependencies', + 'status.pending': 'Pending', + 'status.in_progress': 'In progress', + 'status.completed': 'Completed', +} satisfies Record + +/** Japanese copy, checked against the English keys. */ +export const ja = { + 'progress.completed': '{count}件完了', + 'progress.active': '{count}件進行中', + 'progress.pending': '{count}件保留中', + 'command.title': 'エージェント連携', + 'command.running': '実行中', + 'command.done': '完了', + 'command.failed': '実行失敗', + 'command.failedReason': '実行失敗: {reason}', + 'mode.label': 'エージェント連携を有効にする', + 'mode.infoLabel': 'エージェント連携について', + 'mode.description': '共有タスクボードで複数のエージェントの分担と進捗を調整します。複雑なタスクでより良い結果が得られますが、その分トークンのコストも高くなります。', + 'mode.failed': 'エージェント連携の設定が保存されたことを確認できませんでした。再試行してください: {message}', + 'disable.title': 'エージェント連携を無効にしますか?', + 'disable.description': '会話中にエージェント連携を無効にすると、共有タスクツールとチーム調整用の指示の使用が停止されます。', + 'disable.warning': '無効にすると、キャッシュの再利用に影響し、その後のトークン使用量や費用が増加する可能性があります。また、現在のタスクでの連携に影響する可能性があります。', + 'disable.cancel': 'キャンセル', + 'disable.confirm': '無効にする', + 'disable.close': '閉じる', + 'trigger': 'タスクボード', + 'loading': 'タスクボードを読み込み中…', + 'unavailable': 'タスクボードは利用できません', + 'failure': '保存されたタスクレコードが無効です: {message}', + 'empty': '共有タスクはまだありません。会話から作成してください。', + 'tasks': '共有タスク', + 'owner': 'オーナー', + 'unowned': 'オーナーなし', + 'blocked': '依存関係によりブロック中', + 'status.pending': '保留中', + 'status.in_progress': '進行中', + 'status.completed': '完了', +} satisfies Record + +/** Spanish dictionary, checked against the English keys. */ +export const es = { + 'progress.completed': '{count} completado', + 'progress.active': '{count} en progreso', + 'progress.pending': '{count} pendiente', + 'command.title': 'Colaboración entre agentes', + 'command.running': 'En ejecución', + 'command.done': 'Completado', + 'command.failed': 'Error', + 'command.failedReason': 'Error: {reason}', + 'mode.label': 'Activar colaboración entre agentes', + 'mode.infoLabel': 'Acerca de la colaboración entre agentes', + 'mode.description': 'Coordina el trabajo y el progreso de varios agentes mediante un tablero de tareas compartido, con mejores resultados en tareas complejas y un coste de tokens proporcionalmente mayor.', + 'mode.failed': 'No se pudo confirmar que se guardó la configuración de colaboración entre agentes. Inténtalo de nuevo: {message}', + 'disable.title': '¿Desactivar la colaboración entre agentes?', + 'disable.description': 'Desactivar la colaboración entre agentes durante una conversación detiene el uso de las herramientas de tareas compartidas y las instrucciones de coordinación del equipo.', + 'disable.warning': 'La desactivación puede afectar a la reutilización de la caché, aumentar el consumo posterior de tokens y los costes, y afectar a la colaboración en la tarea actual.', + 'disable.cancel': 'Cancelar', + 'disable.confirm': 'Confirmar desactivación', + 'disable.close': 'Cerrar', + trigger: 'Tablero de tareas', + loading: 'Cargando tablero de tareas…', + unavailable: 'El tablero de tareas no está disponible', + failure: 'Registro de tarea persistente no válido: {message}', + empty: 'Aún no hay tareas compartidas. Créelos a través de la conversación.', + tasks: 'Tareas compartidas', + owner: 'propietario', + unowned: 'Sin dueño', + blocked: 'Bloqueado por dependencias', + 'status.pending': 'Pendiente', + 'status.in_progress': 'En progreso', + 'status.completed': 'Completado', +} satisfies Record + +/** French dictionary, checked against the English keys. */ +export const fr = { + 'progress.completed': '{count} terminé', + 'progress.active': '{count} en cours', + 'progress.pending': '{count} en attente', + 'command.title': 'Collaboration entre agents', + 'command.running': 'En cours', + 'command.done': 'Terminé', + 'command.failed': 'Échec', + 'command.failedReason': 'Échec : {reason}', + 'mode.label': 'Activer la collaboration entre agents', + 'mode.infoLabel': 'À propos de la collaboration entre agents', + 'mode.description': 'Coordonnez la répartition du travail et la progression de plusieurs agents via un tableau de tâches partagé, pour de meilleurs résultats sur les tâches complexes, avec un coût en tokens plus élevé en contrepartie.', + 'mode.failed': 'Impossible de confirmer l’enregistrement du réglage de collaboration entre agents. Réessayez : {message}', + 'disable.title': 'Désactiver la collaboration entre agents ?', + 'disable.description': 'Désactiver la collaboration entre agents pendant une conversation arrête l’utilisation des outils de tâches partagées et des instructions de coordination de l’équipe.', + 'disable.warning': 'La désactivation peut affecter la réutilisation du cache, augmenter la consommation ultérieure de tokens et les coûts, et affecter la collaboration sur la tâche en cours.', + 'disable.cancel': 'Annuler', + 'disable.confirm': 'Confirmer la désactivation', + 'disable.close': 'Fermer', + trigger: 'Tableau des tâches', + loading: 'Chargement du tableau des tâches…', + unavailable: 'Le tableau des tâches n’est pas disponible', + failure: 'Enregistrement de tâche persistant non valide : {message}', + empty: "Aucune tâche partagée pour l'instant. Créez-les à travers la conversation.", + tasks: 'Tâches partagées', + owner: 'Propriétaire', + unowned: 'Sans propriétaire', + blocked: 'Bloqué par les dépendances', + 'status.pending': 'En attente', + 'status.in_progress': 'En cours', + 'status.completed': 'Terminé', +} satisfies Record + +/** German dictionary, checked against the English keys. */ +export const de = { + 'progress.completed': '{count} abgeschlossen', + 'progress.active': '{count} in Bearbeitung', + 'progress.pending': '{count} ausstehend', + 'command.title': 'Agentenzusammenarbeit', + 'command.running': 'Wird ausgeführt', + 'command.done': 'Abgeschlossen', + 'command.failed': 'Fehlgeschlagen', + 'command.failedReason': 'Fehlgeschlagen: {reason}', + 'mode.label': 'Agentenzusammenarbeit aktivieren', + 'mode.infoLabel': 'Über Agentenzusammenarbeit', + 'mode.description': 'Koordiniert die Arbeitsteilung und den Fortschritt mehrerer Agenten über eine gemeinsame Aufgabenübersicht. Erzielt bessere Ergebnisse bei komplexen Aufgaben, bei entsprechend höheren Tokenkosten.', + 'mode.failed': 'Das Speichern der Einstellung zur Agentenzusammenarbeit konnte nicht bestätigt werden. Erneut versuchen: {message}', + 'disable.title': 'Agentenzusammenarbeit deaktivieren?', + 'disable.description': 'Wenn Sie die Agentenzusammenarbeit während eines Gesprächs deaktivieren, werden die Werkzeuge für gemeinsame Aufgaben und die Anweisungen zur Teamkoordination nicht mehr verwendet.', + 'disable.warning': 'Die Deaktivierung kann die Wiederverwendung des Caches beeinträchtigen, den späteren Tokenverbrauch und die Kosten erhöhen und die Zusammenarbeit bei der aktuellen Aufgabe beeinträchtigen.', + 'disable.cancel': 'Abbrechen', + 'disable.confirm': 'Deaktivierung bestätigen', + 'disable.close': 'Schließen', + trigger: 'Aufgabenübersicht', + loading: 'Aufgabenübersicht wird geladen…', + unavailable: 'Aufgabenübersicht ist nicht verfügbar', + failure: 'Ungültiger gespeicherter Aufgabeneintrag: {message}', + empty: 'Noch keine geteilten Aufgaben. Erstellen Sie sie durch das Gespräch.', + tasks: 'Geteilte Aufgaben', + owner: 'Besitzer', + unowned: 'Unbesessen', + blocked: 'Blockiert durch Abhängigkeiten', + 'status.pending': 'Ausstehend', + 'status.in_progress': 'In Bearbeitung', + 'status.completed': 'Abgeschlossen', +} satisfies Record + +/** Russian dictionary, checked against the English keys. */ +export const ru = { + 'progress.completed': '{count} завершено', + 'progress.active': '{count} в процессе', + 'progress.pending': '{count} в ожидании', + 'command.title': 'Совместная работа агентов', + 'command.running': 'Выполняется', + 'command.done': 'Завершено', + 'command.failed': 'Ошибка', + 'command.failedReason': 'Ошибка: {reason}', + 'mode.label': 'Включить совместную работу агентов', + 'mode.infoLabel': 'О совместной работе агентов', + 'mode.description': 'Координируйте распределение работы и прогресс нескольких агентов с помощью общей доски задач: лучшие результаты в сложных задачах при соответственно более высоких затратах на токены.', + 'mode.failed': 'Не удалось подтвердить сохранение настройки совместной работы агентов. Повторите попытку: {message}', + 'disable.title': 'Отключить совместную работу агентов?', + 'disable.description': 'Отключение совместной работы агентов во время разговора прекращает использование инструментов общих задач и инструкций по координации команды.', + 'disable.warning': 'Отключение может повлиять на повторное использование кэша, увеличить последующий расход токенов и затраты, а также повлиять на совместную работу над текущей задачей.', + 'disable.cancel': 'Отмена', + 'disable.confirm': 'Подтвердить отключение', + 'disable.close': 'Закрыть', + trigger: 'Доска задач', + loading: 'Загрузка доски задач…', + unavailable: 'Доска задач недоступна', + failure: 'Недопустимая сохранённая запись задачи: {message}', + empty: 'Общие задачи пока отсутствуют. Создайте их посредством разговора.', + tasks: 'Общие задачи', + owner: 'Владелец', + unowned: 'Невладеющий', + blocked: 'Заблокировано зависимостями', + 'status.pending': 'Ожидается', + 'status.in_progress': 'В процессе', + 'status.completed': 'Завершено', +} satisfies Record + +/** Portuguese dictionary, checked against the English keys. */ +export const pt = { + 'progress.completed': '{count} concluído', + 'progress.active': '{count} em andamento', + 'progress.pending': '{count} pendente', + 'command.title': 'Colaboração entre agentes', + 'command.running': 'Em execução', + 'command.done': 'Concluído', + 'command.failed': 'Falha', + 'command.failedReason': 'Falha: {reason}', + 'mode.label': 'Ativar colaboração entre agentes', + 'mode.infoLabel': 'Sobre a colaboração entre agentes', + 'mode.description': 'Coordene a divisão do trabalho e o progresso de vários agentes com um quadro de tarefas compartilhado, para melhores resultados em tarefas complexas, com custos de tokens proporcionalmente maiores.', + 'mode.failed': 'Não foi possível confirmar que a configuração de colaboração entre agentes foi salva. Tente novamente: {message}', + 'disable.title': 'Desativar a colaboração entre agentes?', + 'disable.description': 'Desativar a colaboração entre agentes durante uma conversa interrompe o uso das ferramentas de tarefas compartilhadas e das instruções de coordenação da equipe.', + 'disable.warning': 'A desativação pode afetar a reutilização do cache, aumentar o consumo posterior de tokens e os custos, e afetar a colaboração na tarefa atual.', + 'disable.cancel': 'Cancelar', + 'disable.confirm': 'Confirmar desativação', + 'disable.close': 'Fechar', + trigger: 'Quadro de tarefas', + loading: 'Carregando quadro de tarefas…', + unavailable: 'O quadro de tarefas não está disponível', + failure: 'Registro de tarefa persistente inválido: {message}', + empty: 'Ainda não há tarefas compartilhadas. Crie-os através da conversa.', + tasks: 'Tarefas compartilhadas', + owner: 'Proprietário', + unowned: 'Sem dono', + blocked: 'Bloqueado por dependências', + 'status.pending': 'Pendente', + 'status.in_progress': 'Em andamento', + 'status.completed': 'Concluído', +} satisfies Record diff --git a/packages/client/ui-agent-team/src/client/mount.ts b/packages/client/ui-agent-team/src/client/mount.ts new file mode 100644 index 0000000000..833267e39e --- /dev/null +++ b/packages/client/ui-agent-team/src/client/mount.ts @@ -0,0 +1,71 @@ +/** Source-safe task-board browser registration. */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-ui-subagent/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { TeamDisableConfirmation, type TeamDisableConfirmationInjected, type TeamDisableRequest } from './TeamDisableConfirmation.tsx' +import { TeamModeToggle, type TeamModeToggleInjected } from './TeamModeToggle.tsx' +import { TeamBoard } from './TeamBoard.tsx' +import { TeamCommandRow } from './TeamCommandRow.tsx' +import { en, ja, es, fr, de, ru, pt, NS, zh, type TeamKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Shared task-board copy. */ + 'agent-team': TeamKey + } +} + +/** Required browser services for slots and localized copy. */ +export const inject = ['slots', 'locale'] + +/** + * Register the task-board dictionaries and subagent catalog sections. + * The panel reads the root Session’s `agentTeam` projection through standard Session hooks. + * @param ctx - Client Context carrying the injected locale and slot services. + */ +export function registerAgentTeamUi(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'client-ui-agent-team: dictionaries') + ctx.slots.inject('conversation.chat.commandview', () => ctx.slots.register({ + name: 'conversation.chat.commandview', key: 'team', locale: NS, + }, TeamCommandRow)) + ctx.inject(['remote', 'remote.agentTeams'], (scope) => { + scope.slots.inject('shell.overlay', function* () { + const disableRequest = createSnapshotStore(null) + const selectTeamMode = async (sessionId: SessionId, enabled: boolean): Promise => { + const response = await scope.remote.agentTeams.setEnabled(sessionId, enabled) + return response.ok ? null : response.error.message + } + yield () => { disableRequest.set(null) } + yield scope.slots.register({ + name: 'shell.overlay', id: 'agent-team.disable', locale: NS, + inject: (): TeamDisableConfirmationInjected => ({ + hooks: { disableRequest }, + cancelDisable: (sessionId) => { + if (disableRequest.getSnapshot()?.sessionId === sessionId) disableRequest.set(null) + }, + disableTeam: sessionId => selectTeamMode(sessionId, false), + }), + }, TeamDisableConfirmation) + yield scope.slots.inject('conversation.input.model.options', () => scope.slots.register({ + name: 'conversation.input.model.options', + id: 'agent-team', + locale: NS, + inject: (sessionId): TeamModeToggleInjected => ({ + selectTeamMode: enabled => selectTeamMode(sessionId, enabled), + requestDisableConfirmation: () => { disableRequest.set({ sessionId }) }, + }), + }, TeamModeToggle)) + }) + }) + ctx.slots.inject('conversation.session.header.actions.subagent.footer', () => ctx.slots.register({ + name: 'conversation.session.header.actions.subagent.footer', id: 'agent-team', locale: NS, + }, TeamBoard)) +} diff --git a/packages/experimental/client-ui-agent-team/src/css-modules.d.ts b/packages/client/ui-agent-team/src/css-modules.d.ts similarity index 100% rename from packages/experimental/client-ui-agent-team/src/css-modules.d.ts rename to packages/client/ui-agent-team/src/css-modules.d.ts diff --git a/packages/experimental/client-ui-agent-team/src/index.ts b/packages/client/ui-agent-team/src/index.ts similarity index 100% rename from packages/experimental/client-ui-agent-team/src/index.ts rename to packages/client/ui-agent-team/src/index.ts diff --git a/packages/client/ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/client/ui-agent-team/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..ba964a033f --- /dev/null +++ b/packages/client/ui-agent-team/tests/browser-plugin.client.spec.ts @@ -0,0 +1,135 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TeamModeToggle, type TeamModeToggleInjected } from '../src/client/TeamModeToggle.tsx' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { TeamBoard } from '../src/client/TeamBoard.tsx' +import { TeamCommandRow } from '../src/client/TeamCommandRow.tsx' +import { TeamDisableConfirmation, type TeamDisableConfirmationInjected } from '../src/client/TeamDisableConfirmation.tsx' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +async function bench() { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(SlotRegistry).await() + const collapseHeader = ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions.subagent.footer': { kind: 'list', scope: 'session' }, + 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = () => ctx.slots.entries('conversation.session.header.actions.subagent.footer') + .find(candidate => candidate.component === TeamBoard) + return { ctx, fiber, entry, collapseHeader } +} + +describe('task-board browser plugin', () => { + it('registers a disposable root task-board section without navigation or Team Remote services', async () => { + const b = await bench() + expect(inject).toEqual(['slots', 'locale']) + expect(b.entry()).toMatchObject({ + options: { id: 'agent-team' }, + locale: 'agent-team', + }) + expect(b.entry()!.inject).toBeUndefined() + expect(b.ctx.slots.entries('conversation.session.header.actions')).toHaveLength(0) + const t = b.ctx.locale.bind('agent-team') + expect(t('trigger')).toBe('Task board') + await b.fiber.dispose() + expect(b.entry()).toBeUndefined() + expect(t('empty')).toBe('empty') + }) + + it('re-registers when the conversation header slot is declared again', async () => { + const b = await bench() + expect(b.entry()).toBeDefined() + b.collapseHeader() + expect(b.entry()).toBeUndefined() + b.ctx.slots.register({ + name: 'root', + children: { 'conversation.session.header.actions.subagent.footer': { kind: 'list', scope: 'session' } }, + } as never, () => null) + await Promise.resolve() + expect(b.entry()).toBeDefined() + await b.fiber.dispose() + }) + + it('registers the mode switch when Agent Teams arrives and reports each execution outcome', async () => { + const b = await bench() + b.ctx.slots.register({ + name: 'conversation.input.model', + children: { 'conversation.input.model.options': { kind: 'list', scope: 'session' } }, + } as never, () => null) + const entry = () => b.ctx.slots.entries('conversation.input.model.options') + .find(candidate => candidate.component === TeamModeToggle) + expect(entry()).toBeUndefined() + const setEnabled = vi.fn() + new TestRemote(b.ctx, { agentTeams: { setEnabled } }) + await vi.waitFor(() => { expect(entry()).toBeDefined() }) + const injected = entry()!.inject as (sessionId: SessionId) => Record + const value: object = injected(SessionId('chosen-session')) + const mode = value as TeamModeToggleInjected + setEnabled.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(mode.selectTeamMode(true)).resolves.toBeNull() + expect(setEnabled).toHaveBeenLastCalledWith('chosen-session', true) + setEnabled.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(mode.selectTeamMode(false)).resolves.toBeNull() + expect(setEnabled).toHaveBeenLastCalledWith('chosen-session', false) + setEnabled.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'connection failed', {}) }) + await expect(mode.selectTeamMode(true)).resolves.toBe('connection failed') + const overlayEntry = () => b.ctx.slots.entries('shell.overlay').find(candidate => candidate.component === TeamDisableConfirmation) + const overlayValue: object = overlayEntry()!.inject!() + const overlay = overlayValue as TeamDisableConfirmationInjected + const request = overlay.hooks.disableRequest + expect(request.getSnapshot()).toBeNull() + const beforeConfirmation = setEnabled.mock.calls.length + mode.requestDisableConfirmation() + expect(request.getSnapshot()).toEqual({ sessionId: 'chosen-session' }) + expect(setEnabled).toHaveBeenCalledTimes(beforeConfirmation) + overlay.cancelDisable(SessionId('other-session')) + expect(request.getSnapshot()).toEqual({ sessionId: 'chosen-session' }) + setEnabled.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(overlay.disableTeam(SessionId('chosen-session'))).resolves.toBeNull() + expect(setEnabled).toHaveBeenLastCalledWith('chosen-session', false) + overlay.cancelDisable(SessionId('chosen-session')) + expect(request.getSnapshot()).toBeNull() + mode.requestDisableConfirmation() + await b.fiber.dispose() + expect(entry()).toBeUndefined() + expect(overlayEntry()).toBeUndefined() + expect(request.getSnapshot()).toBeNull() + }) + + it('binds Team command receipts to the command slot lifetime without Remote services', async () => { + const b = await bench() + const entry = () => b.ctx.slots.entries('conversation.chat.commandview').find(value => value.component === TeamCommandRow) + expect(entry()).toBeUndefined() + b.collapseHeader() + const registerParent = () => b.ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } }, + } as never, () => null) + const collapse = registerParent() + await vi.waitFor(() => { expect(entry()).toBeDefined() }) + expect(entry()).toMatchObject({ options: { key: 'team' }, locale: 'agent-team' }) + expect(entry()!.inject).toBeUndefined() + collapse() + expect(entry()).toBeUndefined() + registerParent() + await vi.waitFor(() => { expect(entry()).toBeDefined() }) + await b.fiber.dispose() + expect(entry()).toBeUndefined() + }) + + it('keeps the node half inert', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) diff --git a/packages/client/ui-agent-team/tests/team-board.client.spec.tsx b/packages/client/ui-agent-team/tests/team-board.client.spec.tsx new file mode 100644 index 0000000000..b09aedc4e5 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-board.client.spec.tsx @@ -0,0 +1,416 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TeamProjection, TeamTaskId, TeamTaskView as TeamTask } from '@deepseek-ai/dsh-agent-team/client' +import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { TeamBoard, type TeamBoardProps } from '../src/client/TeamBoard.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const SESSION = 'root-session-id' as SessionId +const WORKER = 'worker-id' as SessionId +const NESTED = 'nested-id' as SessionId +const TASK_1 = 'task-1' as TeamTaskId +const TASK_2 = 'task-2' as TeamTaskId +const task: TeamTask = { + id: TASK_1, + revision: 1, + subject: 'Implement runtime', + description: 'Build the Team runtime', + status: 'in_progress', + ownerId: SESSION, + blockedBy: [], + writeScopes: ['src'], + ready: false, + writeScopeWarnings: ['write scopes overlap with task-2'], +} +const team: TeamProjection = { enabled: true, tasks: [task] } +type Projections = SessionListState['projectionsBySession'] + +function summary(id: SessionId, title?: string): SessionSummary { + return { + id, ...(title === undefined ? {} : { title }), displayTitle: title ?? id, + running: false, retainedBy: {}, blank: false, updatedAt: 0, + } +} + +function bench(options: { + projections?: Projections + sessionId?: SessionId + byId?: Record + value?: TeamProjection +} = {}) { + const sessionId = options.sessionId ?? SESSION + const byId = options.byId ?? { [SESSION]: summary(SESSION) } + const sessions = createSnapshotStore({ + ids: Object.keys(byId) as SessionId[], byId, phase: 'ready', + projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: options.value ?? team } } }, + }) + const useProjection = ((_key: 'agentTeam', selector?: (value: TeamProjection | undefined) => unknown) => + bindSnapshotSelector(sessions)((state) => { + const value = state.projectionsBySession[sessionId]?.values.agentTeam + return selector === undefined ? value : selector(value) + })) as TeamBoardProps['useProjection'] + const props = { + sessionId, useProjection, + useSessions: bindSnapshotSelector(sessions), + t: makeTranslate(zh, commonZh), + } as TeamBoardProps + return { props, sessions } +} + +function setProjectionSnapshot( + sessions: ReturnType['sessions'], sessionId: SessionId, snapshot: Projections[SessionId], +): void { + act(() => { + const current = sessions.getSnapshot() + sessions.set({ ...current, projectionsBySession: { ...current.projectionsBySession, [sessionId]: snapshot } }) + }) +} + +function setProjection(sessions: ReturnType['sessions'], sessionId: SessionId, value: TeamProjection): void { + setProjectionSnapshot(sessions, sessionId, { state: 'ready', error: null, values: { agentTeam: value } }) +} + +function row(subject = task.subject): HTMLElement { + return screen.getByRole('button', { name: new RegExp(subject, 'u') }) +} + +function ownerValue(): string | null { + return document.querySelector('[data-team-task-owner] span:last-child')?.textContent ?? null +} + +function enableTooltips(): void { + class TooltipObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const size = { inlineSize: 160, blockSize: 30 } + this.callback([{ + target, borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], contentRect: new DOMRect(), + }], this) + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', TooltipObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) +} + +describe('TeamBoard', () => { + it('shows a nonclickable Checklist header and compact initially closed rows', () => { + const b = bench() + render() + const heading = screen.getByRole('heading') + expect(heading.textContent).toBe(`${zh.tasks}${b.props.t('progress.active', { count: 1 })}`) + expect(heading.querySelector('button')).toBeNull() + expect(heading.querySelector('[data-icon="dsw-icon-checklist-outline-regular"]')).toBeTruthy() + expect(screen.getAllByRole('button')).toEqual([row()]) + expect(row().getAttribute('aria-expanded')).toBe('false') + expect(row().textContent).toBe(task.subject) + expect(within(row()).getByRole('img', { name: zh['status.in_progress'] })).toBeTruthy() + expect(row().querySelector('[data-state="ongoing"]')).toBeTruthy() + expect(row().querySelector('[data-icon="dsw-icon-chevron-down-outline-regular"]')).toBeTruthy() + expect(screen.queryByText(task.description)).toBeNull() + expect(screen.queryByText(zh.owner)).toBeNull() + expect(screen.queryByRole('button', { name: '展开' })).toBeNull() + }) + + it('expands the full description before the owner and keeps only one task open', () => { + const longSubject = 'A complete subject that must remain readable when the task is expanded' + const description = 'First line\n'.repeat(30) + 'Final description line' + const b = bench({ value: { ...team, tasks: [ + { ...task, subject: longSubject, description }, + { ...task, id: TASK_2, subject: 'Review implementation', ownerId: WORKER }, + ] } }) + render() + const first = row(longSubject) + fireEvent.click(first) + expect(first.getAttribute('aria-expanded')).toBe('true') + expect(row(longSubject)).toBe(first) + const details = document.getElementById(first.getAttribute('aria-controls')!)! + const owner = details.querySelector('[data-team-task-owner]')! + expect(owner.tagName).toBe('P') + expect(owner.textContent).toBe(`${zh.owner}: ${SESSION}`) + expect(ownerValue()).toBe(SESSION) + const paragraphs = details.querySelectorAll('p') + expect(paragraphs).toHaveLength(2) + expect(paragraphs[0]?.textContent).toBe(description) + expect(paragraphs[1]).toBe(owner) + expect(details.querySelector('dl, dt, dd')).toBeNull() + expect(screen.queryByText(TASK_1)).toBeNull() + expect(screen.queryByText('src')).toBeNull() + expect(screen.queryByText(task.writeScopeWarnings[0]!)).toBeNull() + expect(screen.getAllByRole('button')).toHaveLength(2) + fireEvent.click(row('Review implementation')) + expect(first.getAttribute('aria-expanded')).toBe('false') + expect(ownerValue()).toBe(WORKER) + expect(document.querySelectorAll('[data-team-task-details]')).toHaveLength(1) + expect(screen.queryByText(description)).toBeNull() + fireEvent.click(row('Review implementation')) + expect(ownerValue()).toBeNull() + expect(screen.getAllByRole('button').every(button => button.getAttribute('aria-expanded') === 'false')).toBe(true) + }) + + it('preserves source order, keeps completed tasks, and excludes deleted tombstones from rows and counts', () => { + const b = bench({ value: { ...team, tasks: [ + { ...task, subject: 'Pending first', status: 'pending', ready: true }, + { ...task, id: TASK_2, subject: 'Completed second', status: 'completed' }, + { ...task, id: 'third' as TeamTaskId, subject: 'Active third' }, + { ...task, id: 'deleted' as TeamTaskId, subject: 'Deleted task', status: 'deleted' }, + ] } }) + render() + expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual(['Pending first', 'Completed second', 'Active third']) + expect(screen.getByRole('heading').textContent).toBe(`${zh.tasks}${[ + b.props.t('progress.completed', { count: 1 }), b.props.t('progress.active', { count: 1 }), b.props.t('progress.pending', { count: 1 }), + ].join('\u2002·\u2002')}`) + expect(screen.queryByText('Deleted task')).toBeNull() + expect(row('Pending first').querySelector('[data-state="idle"]')).toBeTruthy() + expect(row('Completed second').querySelector('[data-state="done"]')).toBeTruthy() + fireEvent.click(row('Completed second')) + expect(row('Completed second').getAttribute('aria-expanded')).toBe('true') + expect(ownerValue()).toBe(SESSION) + expect(screen.getByText(task.description)).toBeTruthy() + }) + + it('announces blocked status without displaying readiness, dependencies, scopes, warnings, or task ids', () => { + const { ownerId: _ownerId, ...unowned } = task + const b = bench({ value: { ...team, tasks: [ + { ...unowned, status: 'pending', blockedBy: [TASK_2, 'missing' as TeamTaskId] }, + { ...task, id: TASK_2, subject: 'Prepare shared types', status: 'completed' }, + ] } }) + render() + expect(within(row()).getByRole('img', { name: zh.blocked })).toBeTruthy() + expect(row().querySelector('[data-state="warning"]')).toBeTruthy() + fireEvent.click(row()) + expect(ownerValue()).toBe(zh.unowned) + const details = document.querySelector('[data-team-task-details]')! + expect(details.textContent).toBe(`${task.description}${zh.owner}: ${zh.unowned}`) + expect(details.querySelector('dl, dt, dd')).toBeNull() + for (const metadata of [ + TASK_1, TASK_2, 'Prepare shared types', 'missing', 'src', task.writeScopeWarnings[0]!, zh.blocked, + ]) expect(within(details).queryByText(metadata)).toBeNull() + expect(b.sessions.getSnapshot().projectionsBySession[SESSION]!.values.agentTeam?.tasks[0]).toEqual({ + ...unowned, status: 'pending', blockedBy: [TASK_2, 'missing' as TeamTaskId], + }) + }) + + it('keeps the pending glyph and shows only the owner when description is empty', () => { + render() + fireEvent.click(row()) + expect(within(row()).getByRole('img', { name: zh['status.pending'] })).toBeTruthy() + expect(row().querySelector('[data-state="idle"]')).toBeTruthy() + expect(ownerValue()).toBe(SESSION) + expect(document.querySelector('[data-team-task-details]')?.children).toHaveLength(1) + expect(document.querySelector('[data-team-task-details] p:not([data-team-task-owner])')).toBeNull() + }) + + it('uses the owner id independently of catalog descriptions and Session titles', () => { + const b = bench({ + byId: { [SESSION]: summary(SESSION), [NESTED]: { ...summary(NESTED, 'Session title'), parentId: WORKER } }, + projections: { + [SESSION]: { state: 'ready', error: null, values: { + agentTeam: { ...team, tasks: [{ ...task, ownerId: NESTED }] }, + } }, + [WORKER]: { state: 'ready', error: null, values: { + subagentCatalog: [{ id: NESTED, createdAt: 0, mode: 'continuable', label: 'Catalog responsibility' }], + } }, + }, + }) + render() + fireEvent.click(row()) + expect(ownerValue()).toBe(NESTED) + setProjectionSnapshot(b.sessions, WORKER, { state: 'ready', error: null, values: {} }) + act(() => { b.sessions.update((draft) => { draft.byId[NESTED] = { ...summary(NESTED), displayTitle: 'Display title' } }) }) + expect(ownerValue()).toBe(NESTED) + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, ownerId: WORKER }] }) + expect(ownerValue()).toBe(WORKER) + }) + + it('uses owner id when neither root nor parent projections exist', () => { + const b = bench({ value: { ...team, tasks: [{ ...task, ownerId: NESTED }] }, byId: { + [NESTED]: { ...summary(NESTED), parentId: WORKER }, + } }) + render() + fireEvent.click(row()) + expect(ownerValue()).toBe(NESTED) + }) + + it('hides retained tasks while Team is disabled and shows them when enabled', () => { + const b = bench() + render() + setProjection(b.sessions, SESSION, { ...team, enabled: false }) + expect(screen.queryByRole('region')).toBeNull() + expect(screen.queryByText(task.subject)).toBeNull() + setProjection(b.sessions, SESSION, team) + expect(row()).toBeTruthy() + }) + + it('accepts absent baselines, capability removal, empty tasks, and deleted-only snapshots', () => { + const b = bench({ projections: {} }) + render() + expect(screen.queryByRole('region')).toBeNull() + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: { agentTeam: team } }) + expect(row()).toBeTruthy() + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) + expect(screen.queryByRole('region')).toBeNull() + for (const tasks of [[], [{ ...task, status: 'deleted' as const }]]) { + setProjection(b.sessions, SESSION, { enabled: true, tasks }) + expect(screen.queryByText(zh.empty)).toBeNull() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() + expect(document.querySelector('[data-team-panel]')).toBeNull() + } + }) + + it('reads only the current Session projection', () => { + const b = bench({ sessionId: WORKER }) + render() + expect(screen.queryByRole('region')).toBeNull() + setProjection(b.sessions, WORKER, { ...team, tasks: [{ ...task, subject: 'Current task' }] }) + expect(row('Current task')).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Other task' }] }) + expect(screen.queryByText('Other task')).toBeNull() + }) + + it('keeps failures beside valid tasks but shows only an alert without visible tasks', () => { + const b = bench({ value: { ...team, failure: 'revision is not contiguous' } }) + const view = render() + expect(screen.getByRole('alert').textContent).toBe('任务记录无效:revision is not contiguous') + expect(row()).toBeTruthy() + expect(screen.getByRole('heading')).toBeTruthy() + for (const tasks of [[], [{ ...task, status: 'deleted' as const }]]) { + setProjectionSnapshot(b.sessions, SESSION, { state: 'ready', error: null, values: { + agentTeam: { enabled: true, tasks, failure: 'invalid task' }, + subagentCatalog: [{ id: WORKER, createdAt: 0, mode: 'continuable', label: 'Worker' }], + } }) + const alert = screen.getByRole('alert') + expect(alert.textContent).toBe('任务记录无效:invalid task') + expect(screen.getAllByRole('alert')).toHaveLength(1) + expect(view.container.textContent).toBe(alert.textContent) + expect(view.container.querySelector('[data-with-members="true"]')).toBeNull() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() + expect(screen.queryByText(zh.empty)).toBeNull() + expect(document.querySelector('[data-team-panel]')).toBeNull() + } + setProjection(b.sessions, SESSION, { enabled: false, tasks: [], failure: 'invalid task' }) + expect(view.container.childElementCount).toBe(0) + }) + + it('renders nothing for empty or deleted-only tasks even when members exist', () => { + const b = bench() + const view = render() + for (const tasks of [[], [{ ...task, status: 'deleted' as const }]]) { + setProjectionSnapshot(b.sessions, SESSION, { state: 'ready', error: null, values: { + agentTeam: { enabled: true, tasks }, + subagentCatalog: [{ id: WORKER, createdAt: 0, mode: 'continuable', label: 'Worker' }], + } }) + expect(view.container.childElementCount).toBe(0) + } + }) + + it('keeps live updates and expansion tied to task identity without sorting or polling', () => { + const b = bench() + render() + fireEvent.click(row()) + setProjection(b.sessions, SESSION, { ...team, tasks: [ + { ...task, id: TASK_2, subject: 'New task', status: 'pending', ready: true }, + { ...task, status: 'completed', description: 'Updated details' }, + ] }) + expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual(['New task', task.subject]) + expect(row().getAttribute('aria-expanded')).toBe('true') + expect(row('New task').getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('Updated details')).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, status: 'deleted' }] }) + expect(screen.queryByText('Updated details')).toBeNull() + }) + + it('uses translated English progress and details without Show more or Show less controls', () => { + const b = bench() + render() + expect(screen.getByRole('heading').textContent).toBe(`${en.tasks}${makeTranslate(en)('progress.active', { count: 1 })}`) + fireEvent.click(row()) + expect(ownerValue()).toBe(SESSION) + expect(document.querySelector('[data-team-task-owner]')?.textContent).toBe(`${en.owner}: ${SESSION}`) + expect(screen.queryByText(TASK_1)).toBeNull() + expect(screen.queryByText('Show more')).toBeNull() + expect(screen.queryByText('Show less')).toBeNull() + }) + + it('shows the clipped owner tooltip after 500ms of hover', () => { + vi.useFakeTimers() + enableTooltips() + const ownerId = '8c0404a0-4c67-48ab-9513-50b4a755d6de' as SessionId + const b = bench({ + value: { ...team, tasks: [{ ...task, ownerId }] }, + }) + const view = render() + fireEvent.click(row()) + const owner = screen.getByText(ownerId) + expect(owner.parentElement?.hasAttribute('data-team-task-owner')).toBe(true) + const scrollWidth = vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + fireEvent.pointerEnter(owner) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(owner) + scrollWidth.mockImplementation(function (this: Element) { return this === owner ? 240 : 80 }) + fireEvent.pointerEnter(owner) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe(ownerId) + expect(view.container.contains(screen.getByRole('tooltip'))).toBe(false) + expect(document.querySelectorAll('[data-team-task-owner]')).toHaveLength(1) + fireEvent.click(row()) + expect(screen.queryByRole('tooltip')).toBeNull() + expect(ownerValue()).toBeNull() + }) + + it('delays clipped collapsed subject hover by 500ms while keeping keyboard focus immediate', () => { + vi.useFakeTimers() + enableTooltips() + const view = render() + const button = row() + const subject = screen.getByText(task.subject) + const scrollWidth = vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(80) + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80) + fireEvent.pointerEnter(button) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(button) + scrollWidth.mockImplementation(function (this: Element) { return this === subject ? 180 : 80 }) + fireEvent.pointerEnter(button) + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByRole('tooltip')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe(task.subject) + expect(view.container.contains(screen.getByRole('tooltip'))).toBe(false) + fireEvent.click(button) + expect(row()).toBe(button) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerLeave(button) + fireEvent.pointerEnter(button) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.click(button) + fireEvent.pointerLeave(button) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(button) + expect(screen.getByRole('tooltip').textContent).toBe(task.subject) + }) +}) diff --git a/packages/client/ui-agent-team/tests/team-command.client.spec.tsx b/packages/client/ui-agent-team/tests/team-command.client.spec.tsx new file mode 100644 index 0000000000..999b336108 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-command.client.spec.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { TeamCommandRow, type TeamCommandRowProps } from '../src/client/TeamCommandRow.tsx' +import { zh, en, ja, es, fr, de, ru, pt } from '../src/client/locales.ts' + +afterEach(cleanup) + +type Node = TeamCommandRowProps['node'] +function command(args: string | null, outcome: Node['outcome']): Node { + return { kind: 'command', seq: 1, time: 1, commandId: CommandId('team-command'), name: 'team', args, outcome } +} + +describe.each([ + { id: 'zh', dictionary: zh }, { id: 'en', dictionary: en }, { id: 'ja', dictionary: ja }, { id: 'es', dictionary: es }, + { id: 'fr', dictionary: fr }, { id: 'de', dictionary: de }, { id: 'ru', dictionary: ru }, { id: 'pt', dictionary: pt }, +])('Team command fallback in $id', ({ dictionary }) => { + const t = makeTranslate(dictionary) + + it.each(['status', 'ON', '', null])('shows a localized pending status for unrecognized input %j', (args) => { + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(dictionary['command.running'])).toBeTruthy() + expect(view.container.querySelector('[data-team-command]')?.getAttribute('aria-busy')).toBe('true') + expect(view.container.querySelector('[data-team-command]')?.getAttribute('data-state')).toBe('running') + expect(view.queryByRole('button')).toBeNull() + }) + + it.each(['status', 'ON', '', null])('preserves successful output for unrecognized input %j', (args) => { + const text = 'Agent Teams enabled.' + const node = command(args, { kind: 'success', text }) + const original = JSON.stringify(node) + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(text)).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('[data-team-command]')?.getAttribute('data-state')).toBe('ok') + expect(JSON.stringify(node)).toBe(original) + }) + + it.each([undefined, ''])('uses a localized completed fallback for empty output %j', (text) => { + const outcome: Node['outcome'] = { kind: 'success', ...text === undefined ? {} : { text } } + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(dictionary['command.done'])).toBeTruthy() + }) + + it.each(['on', 'off', 'status', null])('keeps the raw failure expandable for input %j', (args) => { + const raw = ' Save failed\r\nThe original diagnostic remains available.\n' + const node = command(args, { kind: 'error', text: raw }) + const original = JSON.stringify(node) + const view = render() + expect(view.getByText(dictionary['command.title'])).toBeTruthy() + expect(view.getByText(t('command.failedReason', { reason: 'Save failed' }))).toBeTruthy() + expect(view.container.querySelector('[data-team-command]')?.getAttribute('data-state')).toBe('error') + const toggle = view.getByRole('button', { expanded: false }) + fireEvent.keyDown(toggle, { key: 'Enter' }) + expect(view.container.querySelector('pre')?.textContent).toBe(raw) + fireEvent.keyDown(toggle, { key: ' ' }) + expect(view.container.querySelector('pre')).toBeNull() + expect(JSON.stringify(node)).toBe(original) + }) + + it.each([undefined, '', '\n '])('uses a localized failure fallback for empty reason %j', (text) => { + const outcome: Node['outcome'] = { kind: 'error', ...text === undefined ? {} : { text } } + const view = render() + expect(view.getByText(dictionary['command.failed'])).toBeTruthy() + }) +}) + +it('uses the shared agent glyph for a failed command', () => { + const view = render() + const icon = render() + expect(view.container.querySelector('svg')?.outerHTML).toBe(icon.container.querySelector('svg')?.outerHTML) +}) + +it('keeps a long single-line failure expandable', () => { + const raw = 'The original failure detail '.repeat(20) + const view = render() + fireEvent.click(view.getByRole('button', { expanded: false })) + expect(view.container.querySelector('pre')?.textContent).toBe(raw) +}) + +it.each(['success', 'error'] as const)('relocalizes the title without rewriting expanded %s output', (kind) => { + const node = command('status', { kind, text: 'Recorded output\nAdditional recorded detail.' }) + const original = JSON.stringify(node) + const view = render() + expect(view.getByText(en['command.title'])).toBeTruthy() + fireEvent.click(view.getByRole('button', { expanded: false })) + expect(view.container.querySelector('pre')?.textContent).toBe(node.outcome?.text) + view.rerender() + expect(view.getByText(zh['command.title'])).toBeTruthy() + expect(view.container.querySelector('pre')?.textContent).toBe(node.outcome?.text) + expect(JSON.stringify(node)).toBe(original) +}) diff --git a/packages/client/ui-agent-team/tests/team-disable-confirmation-callbacks.client.spec.tsx b/packages/client/ui-agent-team/tests/team-disable-confirmation-callbacks.client.spec.tsx new file mode 100644 index 0000000000..92fa679c4c --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-disable-confirmation-callbacks.client.spec.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import type { ComponentProps } from 'react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import { TeamDisableConfirmation, type TeamDisableConfirmationProps } from '../src/client/TeamDisableConfirmation.tsx' +import { zh } from '../src/client/locales.ts' + +vi.mock('../src/client/TeamDisableConfirmation.module.css', () => ({ default: {} })) +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // Deliver repeated activations to the form even while its native button would suppress them. + Button: (props: ComponentProps) => , + } +}) + +afterEach(cleanup) + +it('keeps one disable operation pending despite repeated callbacks without optional stylesheet classes', async () => { + const pending = Promise.withResolvers() + onTestFinished(async () => { await act(async () => { pending.resolve(null) }) }) + const sessionId = SessionId('pending-disable-session') + const disableTeam = vi.fn(() => pending.promise) + const cancelDisable = vi.fn() + const useDisableRequest: TeamDisableConfirmationProps['useDisableRequest'] = select => select({ sessionId }) + const props = { useDisableRequest, disableTeam, cancelDisable, t: makeTranslate(zh) } as TeamDisableConfirmationProps + render() + const confirm = screen.getByRole('button', { name: zh['disable.confirm'] }) + fireEvent.click(confirm) + expect(confirm.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(confirm) + fireEvent.click(screen.getByRole('button', { name: zh['disable.cancel'] })) + expect(disableTeam).toHaveBeenCalledExactlyOnceWith(sessionId) + expect(cancelDisable).not.toHaveBeenCalled() + expect(screen.getByRole('dialog', { name: zh['disable.title'] })).toBeTruthy() + await act(async () => { pending.resolve(null) }) + expect(cancelDisable).toHaveBeenCalledExactlyOnceWith(sessionId) +}) diff --git a/packages/client/ui-agent-team/tests/team-disable-confirmation.client.spec.tsx b/packages/client/ui-agent-team/tests/team-disable-confirmation.client.spec.tsx new file mode 100644 index 0000000000..58b3fa9004 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-disable-confirmation.client.spec.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom + +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + TeamDisableConfirmation, type TeamDisableConfirmationProps, type TeamDisableRequest, +} from '../src/client/TeamDisableConfirmation.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const ORIGINAL = SessionId('original-session') +const REPLACEMENT = SessionId('replacement-session') + +function props( + request: TeamDisableRequest | null = { sessionId: ORIGINAL }, + disableTeam = vi.fn(async () => null), +) { + const useDisableRequest: TeamDisableConfirmationProps['useDisableRequest'] = select => select(request) + const cancelDisable = vi.fn() + return { + props: { useDisableRequest, cancelDisable, disableTeam, t: makeTranslate(zh) } as TeamDisableConfirmationProps, + cancelDisable, + disableTeam, + } +} + +function pendingDisable() { + const result = Promise.withResolvers() + onTestFinished(async () => { + await act(async () => { result.resolve(null) }) + }) + return result +} + +it('renders nothing when no disable request exists', () => { + const input = props(null) + const view = render() + expect(view.container.childElementCount).toBe(0) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + expect(input.disableTeam).not.toHaveBeenCalled() + expect(input.cancelDisable).not.toHaveBeenCalled() +}) + +it('shows the localized title, two paragraphs, and actions without a checkbox', () => { + const input = props() + render() + const dialog = within(screen.getByRole('dialog', { name: '确认停用智能体团队协作?' })) + expect(dialog.getByRole('heading', { name: '确认停用智能体团队协作?' })).toBeTruthy() + expect(dialog.getByText(zh['disable.description'], { selector: 'p' })).toBeTruthy() + expect(dialog.getByText(zh['disable.warning'], { selector: 'p' })).toBeTruthy() + expect(dialog.getAllByRole('paragraph')).toHaveLength(2) + const cancel = dialog.getByRole('button', { name: zh['disable.cancel'] }) + expect(cancel.hasAttribute('disabled')).toBe(false) + expect(document.activeElement).toBe(cancel) + expect(dialog.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(false) + expect(dialog.getByRole('button', { name: zh['disable.close'] })).toBeTruthy() + expect(dialog.queryByRole('checkbox')).toBeNull() + expect(dialog.queryByRole('alert')).toBeNull() + expect(input.disableTeam).not.toHaveBeenCalled() +}) + +it.each(['cancel', 'close', 'Escape'] as const)('dismisses through %s without disabling Team', (action) => { + const input = props() + const view = render() + if (action === 'Escape') fireEvent.keyDown(document, { key: 'Escape' }) + else fireEvent.click(screen.getByRole('button', { name: zh[`disable.${action}`] })) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + expect(input.disableTeam).not.toHaveBeenCalled() + view.rerender( select(null)} />) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(input.cancelDisable).toHaveBeenCalledTimes(1) +}) + +it('disables the originating Session once and blocks cancellation until success', async () => { + const result = pendingDisable() + const input = props({ sessionId: ORIGINAL }, vi.fn(() => result.promise)) + const view = render() + const confirm = screen.getByRole('button', { name: zh['disable.confirm'] }) + fireEvent.click(confirm) + expect(input.disableTeam).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + expect(confirm.hasAttribute('disabled')).toBe(true) + const cancel = screen.getByRole('button', { name: zh['disable.cancel'] }) + expect(cancel.hasAttribute('disabled')).toBe(true) + fireEvent.click(confirm) + fireEvent.click(cancel) + fireEvent.click(screen.getByRole('button', { name: zh['disable.close'] })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(input.disableTeam).toHaveBeenCalledTimes(1) + expect(input.cancelDisable).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { result.resolve(null) }) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + view.rerender( select(null)} />) + expect(screen.queryByRole('dialog')).toBeNull() +}) + +it.each(['returned', 'error', 'string'] as const)('keeps a %s failure visible and allows retry', async (failure) => { + const result = pendingDisable() + const disableTeam = vi.fn() + .mockImplementationOnce(async () => { + if (failure === 'error') throw new Error('offline') + if (failure === 'string') throw 'offline' + return 'offline' + }) + .mockImplementationOnce(() => result.promise) + const input = props({ sessionId: ORIGINAL }, disableTeam) + const view = render() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) }) + expect(screen.getByRole('alert').textContent).toBe(input.props.t('mode.failed', { message: 'offline' })) + expect(input.cancelDisable).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: zh['disable.cancel'] }).hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(false) + view.rerender( select({ sessionId: ORIGINAL })} />) + expect(screen.getByRole('alert').textContent).toContain('offline') + fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) + expect(disableTeam.mock.calls).toEqual([[ORIGINAL], [ORIGINAL]]) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(true) + await act(async () => { result.resolve(null) }) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) +}) + +it('clears the previous failure when the request targets a different Session', async () => { + const input = props({ sessionId: ORIGINAL }, vi.fn() + .mockResolvedValueOnce('old failure').mockResolvedValueOnce(null)) + const view = render() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) }) + expect(screen.getByRole('alert').textContent).toContain('old failure') + view.rerender( select({ sessionId: REPLACEMENT })} />) + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(false) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) }) + expect(input.disableTeam.mock.calls).toEqual([[ORIGINAL], [REPLACEMENT]]) + expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(REPLACEMENT) +}) + +it.each(['success', 'returned', 'error'] as const)('isolates a replacement request from the old pending %s', async (outcome) => { + const original = pendingDisable() + const replacement = pendingDisable() + const input = props({ sessionId: ORIGINAL }, vi.fn() + .mockImplementationOnce(() => original.promise).mockImplementationOnce(() => replacement.promise)) + const view = render() + fireEvent.click(screen.getByRole('button', { name: zh['disable.confirm'] })) + expect(screen.getByRole('button', { name: zh['disable.confirm'] }).hasAttribute('disabled')).toBe(true) + view.rerender( select({ sessionId: REPLACEMENT })} />) + const confirm = screen.getByRole('button', { name: zh['disable.confirm'] }) + expect(confirm.hasAttribute('disabled')).toBe(false) + expect(screen.getByRole('button', { name: zh['disable.cancel'] }).hasAttribute('disabled')).toBe(false) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(confirm) + expect(input.disableTeam.mock.calls).toEqual([[ORIGINAL], [REPLACEMENT]]) + await act(async () => { + if (outcome === 'error') original.reject(new Error('old failure')) + else original.resolve(outcome === 'success' ? null : 'old failure') + }) + expect(screen.getByRole('dialog', { name: zh['disable.title'] })).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(confirm.hasAttribute('disabled')).toBe(true) + if (outcome === 'success') expect(input.cancelDisable).toHaveBeenCalledExactlyOnceWith(ORIGINAL) + else expect(input.cancelDisable).not.toHaveBeenCalled() + fireEvent.click(confirm) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(input.disableTeam).toHaveBeenCalledTimes(2) + expect(input.cancelDisable).not.toHaveBeenCalledWith(REPLACEMENT) + await act(async () => { replacement.resolve(null) }) + expect(input.cancelDisable.mock.calls).toEqual(outcome === 'success' ? [[ORIGINAL], [REPLACEMENT]] : [[REPLACEMENT]]) +}) diff --git a/packages/client/ui-agent-team/tests/team-mode.client.spec.tsx b/packages/client/ui-agent-team/tests/team-mode.client.spec.tsx new file mode 100644 index 0000000000..13a1da8450 --- /dev/null +++ b/packages/client/ui-agent-team/tests/team-mode.client.spec.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { TeamModeToggle, type TeamModeToggleProps } from '../src/client/TeamModeToggle.tsx' +import { de, en, es, fr, ja, pt, ru, zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +function props(enabled: boolean | undefined, selectTeamMode = vi.fn(async () => null as string | null), child?: 'addressed' | 'listed', requestedModel: boolean | 'loading' = false): TeamModeToggleProps { + return { + sessionId: SessionId('root'), + useSession: (select: (value: { subagent: object | null }) => unknown) => select({ subagent: child === 'addressed' ? {} : null }), + useSessions: (select: (value: { byId: object }) => unknown) => select({ byId: child === 'listed' ? { root: { origin: 'subagent' } } : {} }), + useProjection: (key: string) => key === 'modelSelection' + ? requestedModel === 'loading' ? undefined : { lastUsed: requestedModel ? { provider: 'mock', model: 'mock' } : null, next: { provider: 'mock', model: 'mock' } } + : enabled === undefined ? undefined : { enabled, tasks: [] }, + locked: false, dismiss: vi.fn(), reportFailure: vi.fn(), selectTeamMode, requestDisableConfirmation: vi.fn(), + t: makeTranslate(zh), + } as TeamModeToggleProps +} + +it('renders only available mode and waits for persisted state before displaying a new selection', async () => { + let finish!: (result: string | null) => void + const select = vi.fn(() => new Promise((resolve) => { finish = resolve })) + const view = render() + expect(screen.queryByRole('switch')).toBeNull() + view.rerender() + const toggle = screen.getByRole('switch', { name: '启用智能体团队协作' }) + fireEvent.click(toggle) + expect(select).toHaveBeenCalledWith(true) + expect(toggle.getAttribute('aria-checked')).toBe('false') + expect(toggle.hasAttribute('disabled')).toBe(true) + await act(async () => { finish(null) }) + view.rerender() + expect(toggle.getAttribute('aria-checked')).toBe('true') +}) + +it.each(['returned', 'error', 'string'])('reports rejected mode writes through the menu host (%s)', async (failure) => { + const p = props(false, vi.fn(async () => { + if (failure === 'error') throw new Error('offline') + if (failure === 'string') throw 'offline' + return 'offline' + })) + render() + await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(p.reportFailure).toHaveBeenCalledWith(expect.stringContaining('offline')) + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false') +}) + +it.each(Object.entries({ zh, en, ja, es, fr, de, ru, pt }).flatMap(([locale, dictionary]) => + [false, true].map(locked => ({ locale, dictionary, locked })), +))('opens the $locale explanation without changing Team mode (locked: $locked)', ({ dictionary, locked }) => { + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }) + const input = props(false) + render() + const info = screen.getByRole('button', { name: dictionary['mode.infoLabel'] }) + expect(info.hasAttribute('disabled')).toBe(false) + fireEvent.click(info) + const tooltip = screen.getByRole('tooltip', { hidden: true }) + expect(tooltip.textContent).toBe(dictionary['mode.description']) + expect(info.getAttribute('aria-describedby')).toBe(tooltip.id) + expect(document.body.contains(tooltip)).toBe(true) + expect(input.selectTeamMode).not.toHaveBeenCalled() + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false') + fireEvent.keyDown(info, { key: 'Escape' }) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + fireEvent.click(info) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('tooltip', { hidden: true })).toBeNull() + expect(input.selectTeamMode).not.toHaveBeenCalled() +}) + +it('requests confirmation before disabling after a recorded model request', () => { + const input = props(true, undefined, undefined, true) + render() + fireEvent.click(screen.getByRole('switch')) + expect(input.dismiss).toHaveBeenCalledOnce() + expect(input.dismiss).toHaveBeenCalledBefore(vi.mocked(input.requestDisableConfirmation)) + expect(input.requestDisableConfirmation).toHaveBeenCalledOnce() + expect(input.selectTeamMode).not.toHaveBeenCalled() + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') +}) + +it.each([false, true])('enables directly regardless of model history (requested: %s)', async (requested) => { + const input = props(false, undefined, undefined, requested) + render() + await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(input.selectTeamMode).toHaveBeenCalledWith(true) + expect(input.requestDisableConfirmation).not.toHaveBeenCalled() +}) + +it('disables directly when a model is selected but no request has been recorded', async () => { + const input = props(true) + render() + await act(async () => { fireEvent.click(screen.getByRole('switch')) }) + expect(input.selectTeamMode).toHaveBeenCalledWith(false) + expect(input.requestDisableConfirmation).not.toHaveBeenCalled() +}) + +it('waits for model history before allowing the switch to change', () => { + const input = props(true, undefined, undefined, 'loading') + render() + expect(screen.getByRole('switch').hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('switch')) + expect(input.selectTeamMode).not.toHaveBeenCalled() + expect(input.requestDisableConfirmation).not.toHaveBeenCalled() +}) + +it('disables selection while the Session is locked', () => { + render() + expect(screen.getByRole('switch').hasAttribute('disabled')).toBe(true) +}) + +it.each(['addressed', 'listed'] as const)('hides the switch on a child Session (%s)', (source) => { + const input = props(false, undefined, source) + const useProjection = vi.fn((): never => { throw new Error('Child must not read Team mode') }) + render() + expect(screen.queryByRole('switch')).toBeNull() + expect(useProjection).not.toHaveBeenCalled() + expect(input.selectTeamMode).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-agent-team/tsconfig.json b/packages/client/ui-agent-team/tsconfig.json new file mode 100644 index 0000000000..429b9adac3 --- /dev/null +++ b/packages/client/ui-agent-team/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../team/agent-team" }, + { "path": "../../api/session-controller/tsconfig.client.json" }, + { "path": "../../api/remotes/tsconfig.client.json" }, + { "path": "../ui-model-selection" }, + { "path": "../ui-layout" }, + { "path": "../../core/agent" }, + { "path": "../ui-subagent" }, + { "path": "../locale/tsconfig.client.json" }, + { "path": "../store" }, + { "path": "../ui-conversation/tsconfig.client.json" }, + { "path": "../ui-chat/tsconfig.client.json" }, + { "path": "../../util/values" }, + { "path": "../ui-primitives" }, + { "path": "../ui-renderer" }, + { "path": "../ui-slots" }, + { "path": "../../core/session" } + ] +} diff --git a/packages/experimental/client-ui-agent-team/tsdown.config.ts b/packages/client/ui-agent-team/tsdown.config.ts similarity index 54% rename from packages/experimental/client-ui-agent-team/tsdown.config.ts rename to packages/client/ui-agent-team/tsdown.config.ts index 71d5bd77f8..7aa03cebc1 100644 --- a/packages/experimental/client-ui-agent-team/tsdown.config.ts +++ b/packages/client/ui-agent-team/tsdown.config.ts @@ -1,3 +1,3 @@ -import { clientBundle } from '../../client/tsdown.client.ts' +import { clientBundle } from '../tsdown.client.ts' export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 0966845bde..54168bf257 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -18,11 +18,11 @@ en: 9386791a9e5efa00 zh: 7cb9f3bbc42319d6 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: b0c4e6711d69d2f8 - zh: 09add0ea1a243922 + en: 91b7c7a5263eda35 + zh: 16d39b45edee038f /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: - en: be90c24392fb021b - zh: 992b8745b385f9ab + en: c5b23d0ed29a0425 + zh: 689bab2c8253294a /deepseek-ai-dsh-client-ui-chat/turn-token-usage: en: cfdfe3d99d3f4e09 zh: 9330bad3d3f7a689 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index be43482e8a..b23b9dc3e1 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -54,7 +54,7 @@ Settings → General → Open chat links in selects the destination for ordinary ## Hidden Chat rows -Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. Context containing tool additions or removals remains visible. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. +Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. It also hides pending and successful `/team on` and `/team off` receipts, identified by their recorded name and trimmed arguments; failures and commands with missing or unrecognized arguments remain visible. Context containing tool additions or removals remains visible. The filter changes neither recorded Session events nor Trajectory inspection and excludes hidden receipts from flow spacing and process summaries. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat. When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. @@ -63,6 +63,8 @@ Text and reasoning contribute to Assistant visibility only when they contain a n ## Command and failure rows +Subagent result notices use the Users icon and show only the localized execution outcome in their heading, without a task description or child ID. Completed means that execution ended normally, not that its output passed validation. Historical notices without a readable outcome retain the generic title; unfamiliar outcomes display “Subagent run ended unexpectedly”. Expanded notices preserve their original model-facing body. + Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. Every terminal Turn failure renders its inline red-dot row; a quota failure's row states the neutral `message.failure.quota` copy instead of the provider message. The transient notice for a newly appended `QUOTA` or `ACCOUNT_QUOTA` comes from this package's frame-wide entry in `shell.overlay`, which outlives the Chat panel: it offers the one live notice to the `shell.quota-notice` chain and falls back to its own warning Toast, while an entry that claims the code replaces that fallback. Only Sessions this Client has bound and materialized publish; quota failures in Sessions it never opened do not. A newer notice replaces the current one unless a claiming entry retains it with `keepOpen()`: that call returns a release the caller owns and must run on unmount, any live hold keeps the claiming entry mounted and drops later notices, and releasing resumes later notices without replaying the dropped ones. The fallback Toast has no deferral of its own: while the Desktop account's opaque native Platform page covers the document, it still runs underneath and its display timer may elapse unseen, dismissing the notice itself, so only the persistent failure row remains. A release drops only its own hold, so one that runs after a dismissal or a newer hold leaves that newer hold intact. Dismissal and sign-out clear every hold, and dropped notices are not queued while their persistent failure rows still render. History replacement and pagination never publish a notice. Intermediate retries do not create a terminal row; output-token limits use the amber warning dot. A Turn that restart recovery closed as `interrupted` renders a localized stopped row stating that the DSH process exited mid-Turn, without a code chip, and its process summary reads Stopped. Chat adapts Markdown targets at `MarkdownDelegateProvider.getTriggerProps`. The assistant-node Slot obtains `useContextMenu` directly and passes a file-path adapter to the optional mention service; intermediate flow and node owners carry no menu callbacks. Paths resolve against the originating Session’s current working directory when the gesture begins. Menus retain that captured path and line even if focus changes. Undeclared plugin links keep native behavior. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 9c0314fbd8..e5f8add05b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -54,7 +54,7 @@ Session 作用域的 `conversation.chat.user.content` Factory 只渲染用户内 ## Chat 隐藏的行 -Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 +Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。记录中的命令名和去除首尾空白后的参数确定为 `/team on` 或 `/team off` 时,也隐藏执行中与成功回执;失败以及参数缺失或无法识别的命令仍然可见。包含工具添加或移除记录的上下文仍然可见。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力,隐藏回执也不计入行间距和过程摘要。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 @@ -63,6 +63,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, ## 指令与失败行 +子智能体结果通知使用 Users 图标,标题仅显示本地化的执行状态,不附加任务描述或子智能体 ID。完成表示执行正常结束,不表示输出已通过验证。历史通知没有可读取的结束状态时保留通用标题;未知状态显示“子智能体异常结束”。展开后的通知保留原始模型可见正文。 + 通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要表达。每个终止轮次都渲染自己的红点行;欠费失败的行使用中立的 `message.failure.quota` 文案,而不是提供方消息。新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 产生的瞬时提示来自本包注册在 `shell.overlay` 的全局条目,它不随 Chat 面板消失:该条目把唯一一条实时提示交给 `shell.quota-notice` 链,并在无人接管时回退到自己的警告 Toast,接管该错误码的条目会替换该回退。只有本 Client 已绑定并已物化的 Session 会发布提示;它从未打开的 Session 中的欠费不会发布。更新的提示会替换当前提示,除非认领条目用 `keepOpen()` 保持住当前提示:该调用返回一个由调用方持有、必须在卸载时调用的释放函数,只要存在保持,认领条目就继续挂载,后续提示被丢弃,释放后恢复后续提示但不重放已丢弃的提示。回退 Toast 自身没有延后策略:当 Desktop 账号的不透明原生 Platform 页盖住本页时,它仍会在下方运行,其显示计时器可能在不可见时走完并自行撤下提示,因此只留下持久化失败行。释放只移除自己的保持,因此它在关闭或更新的保持之后执行时不会影响更新的保持。关闭与退出登录会清空所有保持;被丢弃的提示不排队,其对应的持久化失败行仍会渲染。历史替换和分页不会发布提示。中间重试不会创建终止行;输出 token 上限使用琥珀色警告点。重启恢复以 `interrupted` 结束的轮次会渲染本地化的停止行,说明 DSH 进程在轮次中途退出,不显示代码标签,其过程摘要显示“已停止”。 Chat 在 `MarkdownDelegateProvider.getTriggerProps` 接入处适配 Markdown 目标。助手节点 Slot 直接获取 `useContextMenu`,向可选的文件引用服务传入路径适配函数;消息流和节点中间层不再携带菜单回调。手势发生时按原始 Session 的当前工作目录解析路径,菜单保留捕获的路径和行号,不受焦点变化影响。未声明的插件链接保留原生行为。 diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css index 4a860b611f..4bf309274c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.module.css @@ -26,7 +26,7 @@ .header:hover { color: var(--dsw-alias-label-secondary); } } .icon { display: inline-flex; flex: none; color: inherit; } -.title { flex: none; font-size: var(--dsh-content-font-size, 14px); } +.title { flex: 1; min-width: 0; overflow-wrap: anywhere; font-size: var(--dsh-content-font-size, 14px); } .time { flex: none; margin-left: auto; color: inherit; font: var(--dsw-font-xxs-12); } .chevron, .openChevron { flex: none; color: inherit; } .openChevron { transform: rotate(180deg); } diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx index afa2106ca9..e6e366666a 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx @@ -4,7 +4,7 @@ import { IconAgentPresetOutlineRegular, IconClockOutlineRegular, IconBranchOutlineRegular, IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular, - type IconProps, + IconUsersOutlineRegular, type IconProps, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -17,7 +17,7 @@ const TRIGGER_ICONS: Record> = { goal: IconGoalOutlineRegular, agent: IconPaperPlaneOutlineRegular, team: IconAgentPresetOutlineRegular, - subagent: IconAgentPresetOutlineRegular, + subagent: IconUsersOutlineRegular, github: IconBranchOutlineRegular, webhook: IconGlobeOutlineRegular, schedule: IconClockOutlineRegular, diff --git a/packages/client/ui-chat/src/client/chat/turn-trigger.ts b/packages/client/ui-chat/src/client/chat/turn-trigger.ts index d4912668ea..ade8093490 100644 --- a/packages/client/ui-chat/src/client/chat/turn-trigger.ts +++ b/packages/client/ui-chat/src/client/chat/turn-trigger.ts @@ -13,6 +13,20 @@ function field(source: Record, key: string): string { return typeof source[key] === 'string' ? source[key] : '' } +/** Missing or malformed historical reasons stay generic; unfamiliar reasons never imply success. */ +function subagentTitle(reason: unknown): ChatKey { + if (typeof reason !== 'string' || reason === '') return 'message.trigger.subagent' + switch (reason) { + case 'completed': return 'message.trigger.subagent.completed' + case 'aborted': return 'message.trigger.subagent.aborted' + case 'error': return 'message.trigger.subagent.error' + case 'max-tokens': return 'message.trigger.subagent.maxTokens' + case 'refusal': return 'message.trigger.subagent.refusal' + // Backends may extend the recorded reason vocabulary. + default: return 'message.trigger.subagent.unknown' + } +} + /** * Describe a waking message using its recorded source. * @param node - durable context, including the original notification body. @@ -40,11 +54,10 @@ export function turnTriggerDetails(node: ContextMessageNode): { title = 'message.trigger.team' icon = 'team' break - case 'subagent-settled': { - title = 'message.trigger.subagent' + case 'subagent-settled': + title = subagentTitle(source.stopReason) icon = 'subagent' break - } case 'webhook': { const github = field(source, 'provider') === 'github' title = github ? 'message.trigger.github' : 'message.trigger.webhook' diff --git a/packages/client/ui-chat/src/client/contract/chat-visibility.ts b/packages/client/ui-chat/src/client/contract/chat-visibility.ts index 2fd60b4b1d..8f1c6f89b5 100644 --- a/packages/client/ui-chat/src/client/contract/chat-visibility.ts +++ b/packages/client/ui-chat/src/client/contract/chat-visibility.ts @@ -2,15 +2,21 @@ import type { ChatNode } from './chat-nodes.ts' /** - * Exclude system prompts, ordinary Context, and permission commands from visible Chat rows. - * Context containing tool changes retains its notice row. + * Exclude system prompts, ordinary Context, permission commands, and normal Team toggle receipts. + * Failed Team toggles and Context containing tool changes remain visible. * @param node - projected Chat node. * @returns whether the node contributes a visible Chat row. */ export function isVisibleChatNode(node: ChatNode): boolean { + if (node.kind === 'command') { + if (node.data.name === 'permission') return false + if (node.data.name === 'team' && node.data.outcome?.kind !== 'error') { + const selection = node.data.args?.trim() + if (selection === 'on' || selection === 'off') return false + } + } return node.visibility === 'visible' && node.kind !== 'system-prompt' && (node.kind !== 'context' || node.data.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) - && !(node.kind === 'command' && node.data.name === 'permission') } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 6f0e6eda52..221ab256e8 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -55,6 +55,12 @@ export const zh = { 'message.trigger.agent': '收到任务消息', 'message.trigger.team': '收到团队消息', 'message.trigger.subagent': '子任务状态更新', + 'message.trigger.subagent.completed': '子智能体已完成任务', + 'message.trigger.subagent.aborted': '子智能体任务已停止', + 'message.trigger.subagent.error': '子智能体执行失败', + 'message.trigger.subagent.maxTokens': '子智能体已达 token 上限', + 'message.trigger.subagent.refusal': '子智能体拒绝执行', + 'message.trigger.subagent.unknown': '子智能体异常结束', 'message.trigger.github': '收到 GitHub 事件', 'message.trigger.webhook': '收到外部事件', 'message.trigger.schedule': '自动化任务', @@ -257,6 +263,12 @@ export const en = { 'message.trigger.agent': 'Task message received', 'message.trigger.team': 'Team message received', 'message.trigger.subagent': 'Subtask status updated', + 'message.trigger.subagent.completed': 'Subagent run completed', + 'message.trigger.subagent.aborted': 'Subagent run stopped', + 'message.trigger.subagent.error': 'Subagent run failed', + 'message.trigger.subagent.maxTokens': 'Subagent reached the token limit', + 'message.trigger.subagent.refusal': 'Subagent declined the task', + 'message.trigger.subagent.unknown': 'Subagent run ended unexpectedly', 'message.trigger.github': 'GitHub event received', 'message.trigger.webhook': 'External event received', 'message.trigger.schedule': 'Automation task', @@ -456,6 +468,12 @@ export const ja = { 'message.trigger.agent': 'タスクメッセージを受信しました', 'message.trigger.team': 'チームメッセージを受信しました', 'message.trigger.subagent': 'サブタスクの状態が更新されました', + 'message.trigger.subagent.completed': 'サブエージェントの実行が完了しました', + 'message.trigger.subagent.aborted': 'サブエージェントの実行が停止しました', + 'message.trigger.subagent.error': 'サブエージェントの実行が失敗しました', + 'message.trigger.subagent.maxTokens': 'サブエージェントがトークンの上限に達しました', + 'message.trigger.subagent.refusal': 'サブエージェントがタスクを拒否しました', + 'message.trigger.subagent.unknown': 'サブエージェントの実行が予期せず終了しました', 'message.trigger.github': 'GitHubイベントを受信しました', 'message.trigger.webhook': '外部イベントを受信しました', 'message.trigger.schedule': '自動化タスク', @@ -655,6 +673,12 @@ export const es = { 'message.trigger.agent': 'Mensaje de tarea recibido', 'message.trigger.team': 'Mensaje del equipo recibido', 'message.trigger.subagent': 'Estado de subtarea actualizado', + 'message.trigger.subagent.completed': 'Ejecución del subagente completada', + 'message.trigger.subagent.aborted': 'Ejecución del subagente detenida', + 'message.trigger.subagent.error': 'La ejecución del subagente falló', + 'message.trigger.subagent.maxTokens': 'El subagente alcanzó el límite de tokens', + 'message.trigger.subagent.refusal': 'El subagente rechazó la tarea', + 'message.trigger.subagent.unknown': 'La ejecución del subagente terminó de forma inesperada', 'message.trigger.github': 'Evento GitHub recibido', 'message.trigger.webhook': 'Evento externo recibido', 'message.trigger.schedule': 'Tarea de automatización', @@ -854,6 +878,12 @@ export const fr = { 'message.trigger.agent': 'Message de tâche reçu', 'message.trigger.team': "Message d'équipe reçu", 'message.trigger.subagent': 'Statut de la sous-tâche mis à jour', + 'message.trigger.subagent.completed': 'Exécution du sous-agent terminée', + 'message.trigger.subagent.aborted': 'Exécution du sous-agent arrêtée', + 'message.trigger.subagent.error': 'Échec de l’exécution du sous-agent', + 'message.trigger.subagent.maxTokens': 'Le sous-agent a atteint la limite de jetons', + 'message.trigger.subagent.refusal': 'Le sous-agent a refusé la tâche', + 'message.trigger.subagent.unknown': 'L’exécution du sous-agent s’est terminée de façon inattendue', 'message.trigger.github': 'Événement GitHub reçu', 'message.trigger.webhook': 'Événement externe reçu', 'message.trigger.schedule': "Tâche d'automatisation", @@ -1053,6 +1083,12 @@ export const de = { 'message.trigger.agent': 'Aufgabennachricht erhalten', 'message.trigger.team': 'Teamnachricht erhalten', 'message.trigger.subagent': 'Unteraufgabenstatus aktualisiert', + 'message.trigger.subagent.completed': 'Ausführung des Subagenten abgeschlossen', + 'message.trigger.subagent.aborted': 'Ausführung des Subagenten gestoppt', + 'message.trigger.subagent.error': 'Ausführung des Subagenten fehlgeschlagen', + 'message.trigger.subagent.maxTokens': 'Subagent hat das Token-Limit erreicht', + 'message.trigger.subagent.refusal': 'Subagent hat die Aufgabe abgelehnt', + 'message.trigger.subagent.unknown': 'Ausführung des Subagenten unerwartet beendet', 'message.trigger.github': 'GitHub-Ereignis empfangen', 'message.trigger.webhook': 'Externes Ereignis empfangen', 'message.trigger.schedule': 'Automatisierungsaufgabe', @@ -1252,6 +1288,12 @@ export const ru = { 'message.trigger.agent': 'Сообщение о задаче получено', 'message.trigger.team': 'Сообщение команды получено', 'message.trigger.subagent': 'Статус подзадачи обновлен', + 'message.trigger.subagent.completed': 'Работа субагента завершена', + 'message.trigger.subagent.aborted': 'Работа субагента остановлена', + 'message.trigger.subagent.error': 'Работа субагента завершилась с ошибкой', + 'message.trigger.subagent.maxTokens': 'Субагент достиг лимита токенов', + 'message.trigger.subagent.refusal': 'Субагент отказался выполнять задачу', + 'message.trigger.subagent.unknown': 'Работа субагента неожиданно завершилась', 'message.trigger.github': 'Событие GitHub получено', 'message.trigger.webhook': 'Получено внешнее событие', 'message.trigger.schedule': 'Задача автоматизации', @@ -1451,6 +1493,12 @@ export const pt = { 'message.trigger.agent': 'Mensagem de tarefa recebida', 'message.trigger.team': 'Mensagem da equipe recebida', 'message.trigger.subagent': 'Status da subtarefa atualizado', + 'message.trigger.subagent.completed': 'Execução do subagente concluída', + 'message.trigger.subagent.aborted': 'Execução do subagente interrompida', + 'message.trigger.subagent.error': 'Falha na execução do subagente', + 'message.trigger.subagent.maxTokens': 'O subagente atingiu o limite de tokens', + 'message.trigger.subagent.refusal': 'O subagente recusou a tarefa', + 'message.trigger.subagent.unknown': 'A execução do subagente terminou inesperadamente', 'message.trigger.github': 'GitHub evento recebido', 'message.trigger.webhook': 'Evento externo recebido', 'message.trigger.schedule': 'Tarefa de automação', diff --git a/packages/client/ui-chat/tests/chat-visibility.client.spec.ts b/packages/client/ui-chat/tests/chat-visibility.client.spec.ts new file mode 100644 index 0000000000..70b2d38fef --- /dev/null +++ b/packages/client/ui-chat/tests/chat-visibility.client.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { ChatNode } from '../src/client/contract/chat-nodes.ts' +import { isVisibleChatNode } from '../src/client/contract/chat-visibility.ts' + +const placement = { + key: 'command:test', id: 'test', target: 'chat', anchorSeq: 1, + location: { kind: 'unresolved' }, visibility: 'visible', +} as const + +function command(args: string | null, outcome: ChatNode<'command'>['data']['outcome'], name: string | null = 'team'): ChatNode<'command'> { + return { + ...placement, kind: 'command', + data: { kind: 'command', seq: 1, time: 0, commandId: CommandId('test'), name, args, outcome }, + } +} + +describe('Chat-only command visibility', () => { + it.each(['on', 'off', ' on ', '\toff\n'])('omits pending and successful Team selection %j without rewriting the record', (args) => { + for (const outcome of [null, { kind: 'success', text: 'Unchanged recorded output' }] as const) { + const node = command(args, outcome) + const before = JSON.stringify(node) + expect(isVisibleChatNode(node)).toBe(false) + expect(JSON.stringify(node)).toBe(before) + } + }) + + it.each(['on', 'off', null, 'invalid'])('keeps a failed Team command visible for input %j', (args) => { + expect(isVisibleChatNode(command(args, { kind: 'error', text: 'The operation failed' }))).toBe(true) + }) + + it.each(['ON', 'status', '', null])('does not infer a toggle from result text for input %j', (args) => { + expect(isVisibleChatNode(command(args, { kind: 'success', text: 'Agent Teams enabled.' }))).toBe(true) + expect(isVisibleChatNode(command(args, null))).toBe(true) + }) + + it.each(['other', null])('retains unrelated and window-cut commands named %j', (name) => { + expect(isVisibleChatNode(command('on', { kind: 'success', text: 'done' }, name))).toBe(true) + }) + + it('retains a failed toggle after its hidden pending record settles', () => { + const pending = command('on', null) + const failed = { ...pending, data: { ...pending.data, outcome: { kind: 'error' as const, text: 'Save failed' } } } + expect(isVisibleChatNode(pending)).toBe(false) + expect(isVisibleChatNode(failed)).toBe(true) + }) + + it('keeps the existing permission and explicit hidden-node policies', () => { + expect(isVisibleChatNode(command('on', { kind: 'error', text: 'failure' }, 'permission'))).toBe(false) + expect(isVisibleChatNode({ ...command('on', { kind: 'error', text: 'failure' }), visibility: 'hidden' })).toBe(false) + }) +}) + +it('keeps ordinary prompt/context hidden but retains tool changes', () => { + const prompt: ChatNode<'system-prompt'> = { ...placement, kind: 'system-prompt', data: { text: 'system instructions' } } + const context: ChatNode<'context'> = { + ...placement, kind: 'context', + data: { + kind: 'context', seq: 1, time: 0, content: [{ type: 'text', text: 'context' }], source: null, + producer: { role: 'inject', label: null }, form: null, + }, + } + expect(isVisibleChatNode(prompt)).toBe(false) + expect(isVisibleChatNode(context)).toBe(false) + for (const type of ['tool-addition', 'tool-removal'] as const) { + expect(isVisibleChatNode({ ...context, data: { ...context.data, content: [{ type, toolName: 'example' }] } })).toBe(true) + } +}) diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx index c45672dac5..bbb2999cc7 100644 --- a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconClockOutlineRegular, IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNode } from '../src/client/contract/chat-nodes.ts' import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx' import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts' @@ -72,6 +72,37 @@ describe('Turn trigger notices', () => { expect(node.data.source).toEqual({ kind: 'schedule' }) }) + it.each([ + ['completed', 'Subagent run completed', '子智能体已完成任务'], + ['aborted', 'Subagent run stopped', '子智能体任务已停止'], + ['error', 'Subagent run failed', '子智能体执行失败'], + ['max-tokens', 'Subagent reached the token limit', '子智能体已达 token 上限'], + ['refusal', 'Subagent declined the task', '子智能体拒绝执行'], + ['backend-specific', 'Subagent run ended unexpectedly', '子智能体异常结束'], + ])('presents only the recorded %s outcome in both locales', (stopReason, english, chinese) => { + const source = { kind: 'subagent-settled', stopReason, senderSessionId: 'child-1' } + const node = trigger(source) + const view = render() + expect(view.getByRole('button').textContent).toContain(english) + expect(view.getByRole('button').textContent).not.toContain(' · ') + const reference = render() + expect(view.getByRole('button').querySelector('svg')?.outerHTML) + .toBe(reference.container.querySelector('svg')?.outerHTML) + view.rerender() + expect(view.getByRole('button').textContent).toContain(chinese) + expect(view.getByRole('button').textContent).not.toContain(' · ') + fireEvent.click(view.getByRole('button')) + expect(view.container.querySelector('[data-context-text]')?.textContent) + .toBe('Recorded notification\nwith its original body.') + expect(node.data.source).toEqual(source) + }) + + it.each([undefined, null, false, 12, {}, '', []])('does not infer an absent or malformed outcome %j from English text', (stopReason) => { + const node = trigger({ kind: 'subagent-settled', stopReason, summary: 'Background subagent child-1 finished.' }) + node.data.content = [{ type: 'text', text: 'Background subagent child-1 finished.' }] + expect(turnTriggerDetails(node.data)).toEqual({ title: 'message.trigger.subagent', icon: 'subagent' }) + }) + it('opens the recorded notice body and closes it independently of the Turn', () => { const view = render() const button = view.getByRole('button') diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts index f5abacd2a4..95204d468e 100644 --- a/packages/client/ui-commands/src/client/presentation.ts +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -2,7 +2,8 @@ import type { ComponentType } from 'react' import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { - IconCompactOutlineRegular, IconDownloadOutlineRegular, IconGoalOutlineRegular, IconPaperPlaneOutlineRegular, IconPlanOutlineRegular, + IconCompactOutlineRegular, IconDownloadOutlineRegular, + IconGoalOutlineRegular, IconPaperPlaneOutlineRegular, IconPlanOutlineRegular, PermissionIconFullAccessRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f17358e4b5..6c078adbe3 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: cc4dafd1aae02468 zh: be6b67724b7ac4a3 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 99ec447f0a6bda48 - zh: 922cf64666676530 + en: 3fa2fb91ad383c05 + zh: 1e1edd7f9a830377 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: 59cd63057dfb6eeb zh: b2e2c86e810b7bb8 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 14f54dc287..cc60deae1c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -124,7 +124,7 @@ A default send waits for the Host: Enter captures the draft text, its reference Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A refused or failed submission publishes no echo and leaves the draft in the composer with an error notice. A refusal from the Agent admission stage (`session/prompt-rejected`) shows its own text, unless `presented` says the refusing plugin explains it in its own interface, in which case the composer adds no notice. -Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. While a message waits for the Host, the seat shows its progress, and a separate Stop stays beside it. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. While a message waits for the Host, the seat shows its progress, and a separate Stop stays beside it. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; creator availability does not disable input, and Host admission failures use the ordinary prompt error feedback ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 473e15825e..75aa7b7fbe 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -124,7 +124,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。被拒绝或失败的提交不会公开回显,草稿留在 composer 中并显示错误提示。Agent 接纳阶段的拒绝(`session/prompt-rejected`)显示它自带的文字;若 `presented` 表明拒绝它的插件会在自己的界面里说明,composer 不再提示。 -Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。消息等待 Host 接收期间,该位置显示发送进度,旁边另有独立的 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。消息等待 Host 接收期间,该位置显示发送进度,旁边另有独立的 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;创建者的在线状态不禁用输入,Host 接收失败使用普通提示词错误反馈([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。 diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 02897a18c6..9ed94a567b 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -130,7 +130,7 @@ export class InputHub implements SessionInputResolver { quoteInsertionAllowed: () => { const state = session.getSnapshot() return !state.removed && (state.subagent === null - || (state.subagent.address.mode === 'continuable' && state.subagent.parentAvailable === true)) + || state.subagent.address.mode === 'continuable') && this.conversation().blocks.storeFor(session.sessionId).getSnapshot() === undefined }, steerQueue: () => { void this.steerQueue(session, shell) }, diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 2918c177a0..d89b2e91c4 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -20,7 +20,6 @@ export const zh = { 'placeholder.plan': PLAN_NEXT_ACTION_ZH, 'placeholder.default': '发消息或创建任务, / 调用指令, @ 文件或对话', 'placeholder.unavailable': '会话不可用', - 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', @@ -164,6 +163,7 @@ export const zh = { 'tool.title.traceSession': '追踪会话', 'tool.title.listModels': '查看可用模型', 'tool.title.subagent': '创建子智能体', + 'tool.title.subagentFork': '派生子智能体', 'tool.title.listAgents': '查看子智能体', 'tool.title.sendMessage': '发送消息', 'tool.title.interruptAgent': '中断智能体', @@ -185,7 +185,7 @@ export const zh = { 'tool.title.getTeamTask': '读取团队任务', 'tool.title.updateTeamTask': '更新团队任务', 'tool.title.listTeamTasks': '查看团队任务', - 'tool.title.waitAgent': '等待子智能体', + 'tool.title.waitAgent': '等待消息…', 'detail.recordedResult': '调用结果', 'detail.empty': '暂无结果', 'detail.none': '无', @@ -242,11 +242,12 @@ export const zh = { 'detail.field.agents': '启动智能体数', 'detail.field.result': '结果', 'detail.field.parent': '父级', - 'detail.field.depth': '层级', 'detail.field.exitCode': '退出码', 'detail.field.signal': '信号', 'detail.field.previousStatus': '中断前状态', 'detail.field.agent': '智能体 ID', + 'detail.field.agentName': '智能体名称', + 'detail.field.agentDescription': '智能体任务描述', 'detail.field.job': '任务 ID', 'detail.field.task': '任务内容', 'detail.field.processGroup': '进程组', @@ -270,9 +271,9 @@ export const zh = { 'detail.receipt.closed': '已关闭', 'detail.receipt.closing': '关闭中', 'detail.wait.noProgress': '没有正在运行的子智能体', - 'detail.wait.title': '子智能体状态', - 'detail.wait.timeout': '等待超时', - 'detail.wait.changed': '检测到变化', + 'detail.wait.title': '等待结果', + 'detail.wait.timeout': '等待已超时', + 'detail.wait.ended': '等待已结束', 'detail.agent.reply': '智能体回复', 'detail.models.title': '可用模型', 'detail.output.lines': '第 {begin}–{end} 行,共 {total} 行', @@ -410,7 +411,6 @@ export const en = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Message or run a task, / commands, @ files or sessions', 'placeholder.unavailable': 'Session unavailable', - 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', @@ -554,6 +554,7 @@ export const en = { 'tool.title.traceSession': 'Trace session', 'tool.title.listModels': 'List models', 'tool.title.subagent': 'Create subagent', + 'tool.title.subagentFork': 'Fork subagent', 'tool.title.listAgents': 'List subagents', 'tool.title.sendMessage': 'Send message', 'tool.title.interruptAgent': 'Interrupt agent', @@ -575,7 +576,7 @@ export const en = { 'tool.title.getTeamTask': 'Read team task', 'tool.title.updateTeamTask': 'Update team task', 'tool.title.listTeamTasks': 'List team tasks', - 'tool.title.waitAgent': 'Wait for subagent', + 'tool.title.waitAgent': 'Waiting for messages…', 'detail.recordedResult': 'Recorded result', 'detail.empty': 'No results', 'detail.none': 'None', @@ -632,11 +633,12 @@ export const en = { 'detail.field.agents': 'Agents started', 'detail.field.result': 'Result', 'detail.field.parent': 'Parent', - 'detail.field.depth': 'Depth', 'detail.field.exitCode': 'Exit code', 'detail.field.signal': 'Signal', 'detail.field.previousStatus': 'Previous status', 'detail.field.agent': 'Agent ID', + 'detail.field.agentName': 'Agent name', + 'detail.field.agentDescription': 'Agent task description', 'detail.field.job': 'Job ID', 'detail.field.task': 'Task', 'detail.field.processGroup': 'Process group', @@ -660,9 +662,9 @@ export const en = { 'detail.receipt.closed': 'Closed', 'detail.receipt.closing': 'Closing', 'detail.wait.noProgress': 'No active subagents', - 'detail.wait.title': 'Subagent activity', + 'detail.wait.title': 'Wait result', 'detail.wait.timeout': 'Wait timed out', - 'detail.wait.changed': 'Change detected', + 'detail.wait.ended': 'Wait ended', 'detail.agent.reply': 'Agent response', 'detail.models.title': 'Available models', 'detail.output.lines': 'Lines {begin}–{end} of {total}', @@ -799,7 +801,6 @@ export const ja = { 'placeholder.plan': '計画を生成するにはタスクを説明してください', 'placeholder.default': 'メッセージを送信するかタスクを実行、/ コマンド、@ ファイルやセッション', 'placeholder.unavailable': 'セッションを利用できません', - 'placeholder.parentOffline': '親セッションがオフラインです。送信はできませんが、実行は停止できます', 'placeholder.hero': '作りたいものを説明してください。/ コマンド、@ ファイルやセッション', 'placeholder.workspace': '開始するワークスペースを選択', 'placeholder.steerQueue': 'Cmd/Ctrl+Enterでキュー内のすべてのメッセージを実行中の指示に追加', @@ -943,6 +944,7 @@ export const ja = { 'tool.title.traceSession': 'セッションをトレース', 'tool.title.listModels': 'モデルを一覧表示', 'tool.title.subagent': 'サブエージェントを作成', + 'tool.title.subagentFork': 'サブエージェントを作成(コンテキストを継承)', 'tool.title.listAgents': 'サブエージェントを一覧表示', 'tool.title.sendMessage': 'メッセージを送信', 'tool.title.interruptAgent': 'エージェントを中断', @@ -964,7 +966,7 @@ export const ja = { 'tool.title.getTeamTask': 'チームタスクを読み取り', 'tool.title.updateTeamTask': 'チームタスクを更新', 'tool.title.listTeamTasks': 'チームタスクを一覧表示', - 'tool.title.waitAgent': 'サブエージェントを待機', + 'tool.title.waitAgent': 'メッセージを待機中…', 'detail.recordedResult': '記録された結果', 'detail.empty': '結果なし', 'detail.none': 'なし', @@ -1021,11 +1023,12 @@ export const ja = { 'detail.field.agents': '起動したエージェント', 'detail.field.result': '結果', 'detail.field.parent': '親', - 'detail.field.depth': '深さ', 'detail.field.exitCode': '終了コード', 'detail.field.signal': 'シグナル', 'detail.field.previousStatus': '以前のステータス', 'detail.field.agent': 'エージェント ID', + 'detail.field.agentName': 'エージェント名', + 'detail.field.agentDescription': 'エージェントのタスク説明', 'detail.field.job': 'ジョブ ID', 'detail.field.task': 'タスク', 'detail.field.processGroup': 'プロセスグループ', @@ -1051,7 +1054,7 @@ export const ja = { 'detail.wait.noProgress': 'アクティブなサブエージェントなし', 'detail.wait.title': 'サブエージェントのアクティビティ', 'detail.wait.timeout': '待機がタイムアウトしました', - 'detail.wait.changed': '変更を検出', + 'detail.wait.ended': '待機が終了しました', 'detail.agent.reply': 'エージェントの応答', 'detail.models.title': '利用可能なモデル', 'detail.output.lines': '{total} 行中 {begin}–{end} 行', @@ -1186,7 +1189,6 @@ export const es = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Envía mensajes o ejecuta una tarea, / comandos, @ archivos o sesiones', 'placeholder.unavailable': 'Sesión no disponible', - 'placeholder.parentOffline': 'Sesión para padres sin conexión; El envío no está disponible pero aún puedes detener la ejecución.', 'placeholder.hero': 'Describe lo que quieres construir, / comandos, @ archivos o sesiones', 'placeholder.workspace': 'Elija un espacio de trabajo para comenzar', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter dirige todos los mensajes en cola', @@ -1325,6 +1327,7 @@ export const es = { 'tool.title.traceSession': 'Sesión de seguimiento', 'tool.title.listModels': 'Listar modelos', 'tool.title.subagent': 'Crear subagente', + 'tool.title.subagentFork': 'Crear subagente (heredar contexto)', 'tool.title.listAgents': 'Listar subagentes', 'tool.title.sendMessage': 'Enviar mensaje', 'tool.title.interruptAgent': 'agente de interrupción', @@ -1346,7 +1349,7 @@ export const es = { 'tool.title.getTeamTask': 'Leer tarea de equipo', 'tool.title.updateTeamTask': 'Actualizar tarea de equipo', 'tool.title.listTeamTasks': 'Listar tareas del equipo', - 'tool.title.waitAgent': 'Espere al subagente', + 'tool.title.waitAgent': 'Esperando mensajes…', 'detail.recordedResult': 'Resultado registrado', 'detail.empty': 'Sin resultados', 'detail.none': 'Ninguno', @@ -1403,11 +1406,12 @@ export const es = { 'detail.field.agents': 'Los agentes comenzaron', 'detail.field.result': 'Resultado', 'detail.field.parent': 'Padre', - 'detail.field.depth': 'Profundidad', 'detail.field.exitCode': 'código de salida', 'detail.field.signal': 'señal', 'detail.field.previousStatus': 'Estado anterior', 'detail.field.agent': 'ID del agente', + 'detail.field.agentName': 'Nombre del agente', + 'detail.field.agentDescription': 'Descripción de la tarea del agente', 'detail.field.job': 'ID de trabajo', 'detail.field.task': 'Tarea', 'detail.field.processGroup': 'grupo de procesos', @@ -1433,7 +1437,7 @@ export const es = { 'detail.wait.noProgress': 'No hay subagentes activos', 'detail.wait.title': 'Actividad del subagente', 'detail.wait.timeout': 'Se agotó el tiempo de espera', - 'detail.wait.changed': 'Cambio detectado', + 'detail.wait.ended': 'Espera finalizada', 'detail.agent.reply': 'Respuesta del agente', 'detail.models.title': 'Modelos disponibles', 'detail.output.lines': 'Líneas {begin}–{end} de {total}', @@ -1575,7 +1579,6 @@ export const fr = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Envoyez un message ou exécutez une tâche, des commandes /, des fichiers @ ou des sessions', 'placeholder.unavailable': 'Séance indisponible', - 'placeholder.parentOffline': "Session parent hors ligne ; l'envoi n'est pas disponible mais vous pouvez toujours arrêter l'exécution", 'placeholder.hero': 'Décrivez ce que vous souhaitez construire, / commandes, @ fichiers ou sessions', 'placeholder.workspace': 'Choisissez un espace de travail pour commencer', 'placeholder.steerQueue': "Cmd/Ctrl+Entrée dirige tous les messages en file d'attente", @@ -1714,6 +1717,7 @@ export const fr = { 'tool.title.traceSession': 'Session de traçage', 'tool.title.listModels': 'Liste des modèles', 'tool.title.subagent': 'Créer un sous-agent', + 'tool.title.subagentFork': 'Créer un sous-agent (hériter du contexte)', 'tool.title.listAgents': 'Liste des sous-agents', 'tool.title.sendMessage': 'Envoyer le message', 'tool.title.interruptAgent': "Agent d'interruption", @@ -1735,7 +1739,7 @@ export const fr = { 'tool.title.getTeamTask': "Lire la tâche d'équipe", 'tool.title.updateTeamTask': "Mettre à jour la tâche de l'équipe", 'tool.title.listTeamTasks': "Répertorier les tâches de l'équipe", - 'tool.title.waitAgent': 'Attendez le sous-agent', + 'tool.title.waitAgent': 'En attente de messages…', 'detail.recordedResult': 'Résultat enregistré', 'detail.empty': 'Aucun résultat', 'detail.none': 'Aucun', @@ -1792,11 +1796,12 @@ export const fr = { 'detail.field.agents': 'Les agents ont commencé', 'detail.field.result': 'Résultat', 'detail.field.parent': 'Parent', - 'detail.field.depth': 'Profondeur', 'detail.field.exitCode': 'Code de sortie', 'detail.field.signal': 'Signal', 'detail.field.previousStatus': 'Statut précédent', 'detail.field.agent': "ID d'agent", + 'detail.field.agentName': 'Nom de l’agent', + 'detail.field.agentDescription': 'Description de la tâche de l’agent', 'detail.field.job': 'ID de travail', 'detail.field.task': 'Tâche', 'detail.field.processGroup': 'Groupe de processus', @@ -1822,7 +1827,7 @@ export const fr = { 'detail.wait.noProgress': 'Aucun sous-agent actif', 'detail.wait.title': 'Activité du sous-agent', 'detail.wait.timeout': "Le délai d'attente a expiré", - 'detail.wait.changed': 'Changement détecté', + 'detail.wait.ended': 'Attente terminée', 'detail.agent.reply': "Réponse de l'agent", 'detail.models.title': 'Modèles disponibles', 'detail.output.lines': 'Lignes {begin}–{end} de {total}', @@ -1964,7 +1969,6 @@ export const de = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Senden Sie eine Nachricht oder führen Sie eine Aufgabe, /-Befehle, @-Dateien oder Sitzungen aus', 'placeholder.unavailable': 'Sitzung nicht verfügbar', - 'placeholder.parentOffline': 'Elternsitzung offline; Das Senden ist nicht möglich, aber Sie können den Lauf trotzdem stoppen', 'placeholder.hero': 'Beschreiben Sie, was Sie erstellen möchten: /-Befehle, @-Dateien oder Sitzungen', 'placeholder.workspace': 'Wählen Sie einen Arbeitsbereich zum Starten aus', 'placeholder.steerQueue': 'Cmd/Strg+Enter steuert alle Nachrichten in der Warteschlange', @@ -2103,6 +2107,7 @@ export const de = { 'tool.title.traceSession': 'Trace-Sitzung', 'tool.title.listModels': 'Modelle auflisten', 'tool.title.subagent': 'Subagent erstellen', + 'tool.title.subagentFork': 'Subagent erstellen (Kontext übernehmen)', 'tool.title.listAgents': 'Subagenten auflisten', 'tool.title.sendMessage': 'Nachricht senden', 'tool.title.interruptAgent': 'Interrupt-Agent', @@ -2124,7 +2129,7 @@ export const de = { 'tool.title.getTeamTask': 'Teamaufgabe lesen', 'tool.title.updateTeamTask': 'Teamaufgabe aktualisieren', 'tool.title.listTeamTasks': 'Teamaufgaben auflisten', - 'tool.title.waitAgent': 'Warten Sie auf den Subagenten', + 'tool.title.waitAgent': 'Warten auf Nachrichten…', 'detail.recordedResult': 'Aufgezeichnetes Ergebnis', 'detail.empty': 'Keine Ergebnisse', 'detail.none': 'Keine', @@ -2181,11 +2186,12 @@ export const de = { 'detail.field.agents': 'Agenten begannen', 'detail.field.result': 'Ergebnis', 'detail.field.parent': 'Eltern', - 'detail.field.depth': 'Tiefe', 'detail.field.exitCode': 'Exit-Code', 'detail.field.signal': 'Signal', 'detail.field.previousStatus': 'Vorheriger Stand', 'detail.field.agent': 'Agenten-ID', + 'detail.field.agentName': 'Agentenname', + 'detail.field.agentDescription': 'Aufgabenbeschreibung des Agenten', 'detail.field.job': 'Job-ID', 'detail.field.task': 'Aufgabe', 'detail.field.processGroup': 'Prozessgruppe', @@ -2211,7 +2217,7 @@ export const de = { 'detail.wait.noProgress': 'Keine aktiven Subagenten', 'detail.wait.title': 'Aktivität des Subagenten', 'detail.wait.timeout': 'Wartezeit abgelaufen', - 'detail.wait.changed': 'Änderung erkannt', + 'detail.wait.ended': 'Warten beendet', 'detail.agent.reply': 'Antwort des Agenten', 'detail.models.title': 'Verfügbare Modelle', 'detail.output.lines': 'Linien {begin}–{end} von {total}', @@ -2353,7 +2359,6 @@ export const ru = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Отправьте сообщение или запустите задачу, /команды, @файлы или сеансы', 'placeholder.unavailable': 'Сеанс недоступен', - 'placeholder.parentOffline': 'Родительская сессия в автономном режиме; отправка недоступна, но вы все равно можете остановить запуск', 'placeholder.hero': 'Опишите, что вы хотите построить: /команды, @файлы или сеансы.', 'placeholder.workspace': 'Выберите рабочее пространство для начала', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter управляет всеми сообщениями в очереди.', @@ -2492,6 +2497,7 @@ export const ru = { 'tool.title.traceSession': 'Трассировка сеанса', 'tool.title.listModels': 'Список моделей', 'tool.title.subagent': 'Создать субагент', + 'tool.title.subagentFork': 'Создать субагент (наследовать контекст)', 'tool.title.listAgents': 'Список субагентов', 'tool.title.sendMessage': 'Отправить сообщение', 'tool.title.interruptAgent': 'Агент прерываний', @@ -2513,7 +2519,7 @@ export const ru = { 'tool.title.getTeamTask': 'Прочитать командное задание', 'tool.title.updateTeamTask': 'Обновить групповую задачу', 'tool.title.listTeamTasks': 'Перечислить командные задачи', - 'tool.title.waitAgent': 'Подождите субагента', + 'tool.title.waitAgent': 'Ожидание сообщений…', 'detail.recordedResult': 'Записанный результат', 'detail.empty': 'Нет результатов', 'detail.none': 'Нет', @@ -2570,11 +2576,12 @@ export const ru = { 'detail.field.agents': 'Агенты запущены', 'detail.field.result': 'Результат', 'detail.field.parent': 'Родитель', - 'detail.field.depth': 'Глубина', 'detail.field.exitCode': 'Код выхода', 'detail.field.signal': 'Сигнал', 'detail.field.previousStatus': 'Предыдущий статус', 'detail.field.agent': 'Идентификатор агента', + 'detail.field.agentName': 'Имя агента', + 'detail.field.agentDescription': 'Описание задачи агента', 'detail.field.job': 'Идентификатор вакансии', 'detail.field.task': 'Задача', 'detail.field.processGroup': 'Группа процессов', @@ -2600,7 +2607,7 @@ export const ru = { 'detail.wait.noProgress': 'Нет активных субагентов', 'detail.wait.title': 'Субагентская деятельность', 'detail.wait.timeout': 'Время ожидания истекло', - 'detail.wait.changed': 'Обнаружено изменение', + 'detail.wait.ended': 'Ожидание завершено', 'detail.agent.reply': 'Ответ агента', 'detail.models.title': 'Доступные модели', 'detail.output.lines': 'Линии {begin} – {end} из {total}', @@ -2742,7 +2749,6 @@ export const pt = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Envie mensagens ou execute uma tarefa, / comandos, @ arquivos ou sessões', 'placeholder.unavailable': 'Sessão indisponível', - 'placeholder.parentOffline': 'Sessão pai offline; o envio não está disponível, mas você ainda pode interromper a execução', 'placeholder.hero': 'Descreva o que você deseja construir, / comandos, @ arquivos ou sessões', 'placeholder.workspace': 'Escolha um espaço de trabalho para começar', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter direciona todas as mensagens na fila', @@ -2881,6 +2887,7 @@ export const pt = { 'tool.title.traceSession': 'Sessão de rastreamento', 'tool.title.listModels': 'Listar modelos', 'tool.title.subagent': 'Criar subagente', + 'tool.title.subagentFork': 'Criar subagente (herdar contexto)', 'tool.title.listAgents': 'Listar subagentes', 'tool.title.sendMessage': 'Enviar mensagem', 'tool.title.interruptAgent': 'Agente de interrupção', @@ -2902,7 +2909,7 @@ export const pt = { 'tool.title.getTeamTask': 'Ler tarefa da equipe', 'tool.title.updateTeamTask': 'Atualizar tarefa da equipe', 'tool.title.listTeamTasks': 'Listar tarefas da equipe', - 'tool.title.waitAgent': 'Aguarde o subagente', + 'tool.title.waitAgent': 'Aguardando mensagens…', 'detail.recordedResult': 'Resultado registrado', 'detail.empty': 'Nenhum resultado', 'detail.none': 'Nenhum', @@ -2959,11 +2966,12 @@ export const pt = { 'detail.field.agents': 'Agentes iniciados', 'detail.field.result': 'Resultado', 'detail.field.parent': 'Pai', - 'detail.field.depth': 'Profundidade', 'detail.field.exitCode': 'Código de saída', 'detail.field.signal': 'Sinal', 'detail.field.previousStatus': 'Estado anterior', 'detail.field.agent': 'ID do agente', + 'detail.field.agentName': 'Nome do agente', + 'detail.field.agentDescription': 'Descrição da tarefa do agente', 'detail.field.job': 'ID do trabalho', 'detail.field.task': 'Tarefa', 'detail.field.processGroup': 'Grupo de processos', @@ -2989,7 +2997,7 @@ export const pt = { 'detail.wait.noProgress': 'Nenhum subagente ativo', 'detail.wait.title': 'Atividade do subagente', 'detail.wait.timeout': 'A espera expirou', - 'detail.wait.changed': 'Alteração detectada', + 'detail.wait.ended': 'Espera encerrada', 'detail.agent.reply': 'Resposta do agente', 'detail.models.title': 'Modelos disponíveis', 'detail.output.lines': 'Linhas {begin}–{end} de {total}', diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx index 674b644a9f..dc82d99775 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx @@ -29,15 +29,7 @@ export function ConversationMainPanel(props: ConversationSlotProps) { // The exemption is deliberately open-state-wide, not loading-only: a // summary-blank session is the hero before its open starts (`cold`) and // after one fails (`error`) for the same reason — there is no history. - // A restored continuable subagent waits for a Host summary to establish - // parent availability. This keeps the composer - // hidden instead of briefly rendering the parent-offline takeover. - const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined - const settling = sessionId !== undefined && ( - (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending - ) + const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true const hero = sessionId === undefined || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) const phase = settling ? 'settling' : hero ? 'hero' : 'active' diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index cfe0953095..dd60f4cff6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -149,11 +149,6 @@ cursor: pointer; } -.crumbSubagent { - font-size: 12px; - line-height: 18px; -} - @media (hover: hover) { button.crumb:hover { background: var(--dsw-alias-interactive-bg-hover); @@ -170,7 +165,13 @@ display: flex; flex: none; align-items: center; - gap: 8px; + gap: 10px; +} + +@container (max-width: 460px) { + .headerActions { + gap: 8px; + } } .headerUtilities { diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 14602cbca0..cc309bbfab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -157,14 +157,14 @@ export function ConversationSessionHeader({ // base.css) and leave the title inert for dragging too. const title = last ? ( - + {summary.displayTitle} ) : ( + + } + )} {browsable && 0} icon={} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} diff --git a/packages/client/ui-entry-customization/src/client/locales.ts b/packages/client/ui-entry-customization/src/client/locales.ts index c0a0e2cccc..c9191fb438 100644 --- a/packages/client/ui-entry-customization/src/client/locales.ts +++ b/packages/client/ui-entry-customization/src/client/locales.ts @@ -1,5 +1,7 @@ /** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */ export const en = { + 'pinTooltip': 'Pin to left sidebar', + 'pin': 'Pin {name} to sidebar', install: 'Browse other plugins', create: 'Create your plugin', 'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.', @@ -9,6 +11,8 @@ export const en = { /** Chinese counterpart of the entry-customization dictionary. */ export const zh: Record = { + 'pinTooltip': '固定到左侧栏', + 'pin': '将{name}固定到侧边栏', install: '浏览更多插件', create: '创建你的插件', 'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。', @@ -18,6 +22,8 @@ export const zh: Record = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'pinTooltip': '左サイドバーに固定', + 'pin': '{name}をサイドバーに固定', 'install': '他のプラグインを探す', 'create': 'プラグインを作成', 'createPrompt.sidebar': '左サイドバーにエントリを持つ DSH プラグインを作成します。', @@ -27,6 +33,8 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { + 'pinTooltip': 'Fijar a la barra lateral izquierda', + 'pin': 'Fijar {name} en la barra lateral', install: 'Explorar otros complementos', create: 'Crea tu complemento', 'createPrompt.sidebar': 'Cree un complemento DSH con una entrada en la barra lateral izquierda.', @@ -36,6 +44,8 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { + 'pinTooltip': 'Épingler sur la barre latérale gauche', + 'pin': 'Épingler {name} à la barre latérale', install: "Parcourir d'autres plugins", create: 'Créez votre plugin', 'createPrompt.sidebar': 'Créez un plugin DSH avec une entrée dans la barre latérale gauche.', @@ -45,6 +55,8 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { + 'pinTooltip': 'An die linke Seitenleiste anpinnen', + 'pin': '{name} an der Seitenleiste anheften', install: 'Durchsuchen Sie andere Plugins', create: 'Erstellen Sie Ihr Plugin', 'createPrompt.sidebar': 'Erstellen Sie ein DSH-Plugin mit einem Eintrag in der linken Seitenleiste.', @@ -54,6 +66,8 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { + 'pinTooltip': 'Закрепить на левой боковой панели', + 'pin': 'Закрепить {name} на боковой панели', install: 'Просмотрите другие плагины', create: 'Создайте свой плагин', 'createPrompt.sidebar': 'Создайте плагин DSH с записью на левой боковой панели.', @@ -63,6 +77,8 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'pinTooltip': 'Fixar na barra lateral esquerda', + 'pin': 'Fixar {name} na barra lateral', install: 'Procure outros plug-ins', create: 'Crie seu plug-in', 'createPrompt.sidebar': 'Crie um plug-in DSH com uma entrada na barra lateral esquerda.', diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index ff77c49ca7..334352d679 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -2,11 +2,12 @@ /** The plugin fills each region's More menu with the discovery and Creator actions. */ import type { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' import type { ComponentProps } from 'react' import { useState } from 'react' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client' import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx' @@ -35,16 +36,16 @@ function content(overrides: Partial = {}) { region: 'sidebar', t: makeTranslate(en), usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, - useResource: unused, useWorkspaces: unused, + useResource: unused, useWorkspaces: unused, renderFactorySlot: () => null, ...callbacks, ...overrides, } as ContentProps const view = render(
    ) - return { view, ...callbacks } + return { view, props, ...callbacks } } describe('More menu rows', () => { - it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { + it.each(['rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => { const b = content({ region, entries: [ { id: 'notes', label: 'Notes', pinned: true, active: true }, { id: 'tasks', label: 'Tasks', pinned: false }, @@ -61,6 +62,37 @@ describe('More menu rows', () => { expect(b.onPinnedChange).not.toHaveBeenCalled() }) + it('lists only unpinned sidebar entries and pins without navigating', () => { + const b = content() + expect(b.view.queryByRole('menuitem', { name: 'Notes' })).toBeNull() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' })) + expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks') + expect(b.onPinnedChange).not.toHaveBeenCalled() + b.onOpen.mockClear() + fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin Tasks to sidebar' })) + expect(b.onPinnedChange).toHaveBeenCalledExactlyOnceWith('tasks', true) + expect(b.onOpen).not.toHaveBeenCalled() + }) + + it('returns focus to More after keyboard pinning without opening the entry', async () => { + const b = content() + b.view.unmount() + function PinMenu() { + const [open, setOpen] = useState(true) + return { setOpen(false) }} autoFocus + anchor={}> + { setOpen(false) }} /> + + } + const view = render() + const pin = view.getByRole('menuitem', { name: 'Pin Tasks to sidebar' }) + act(() => { pin.focus() }) + fireEvent.keyDown(pin, { key: 'Tab' }) + await waitFor(() => { expect(document.activeElement).toBe(view.getByRole('button', { name: 'More' })) }) + expect(b.onPinnedChange).toHaveBeenCalledExactlyOnceWith('tasks', true) + expect(b.onOpen).not.toHaveBeenCalled() + }) + it.each([true, false])('opens folded Views and marks the active View with plugin navigation present=%s', (browsable) => { const b = content({ region: 'conversation', usePluginNavigation: select => select(browsable), @@ -90,7 +122,7 @@ describe('More menu rows', () => { }) it.each(['sidebar', 'rightbar'] as const)('keeps only creation in %s without plugin navigation', (region) => { - const b = content({ region, usePluginNavigation: select => select(false) }) + const b = content({ region, entries: [], usePluginNavigation: select => select(false) }) expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Create your plugin']) expect(b.view.queryByRole('separator')).toBeNull() fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' })) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 9f8db4d72f..4d2ee72e3b 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-jobs/use-this-package: - en: 60f6d05d870f6518 - zh: dddf3f532d828b78 + en: 3466553808f81088 + zh: 02a806ece1c3e012 /deepseek-ai-dsh-client-ui-jobs/use-this-package/one-row-per-job: en: 64c1f55b35e73295 zh: 4d93594923cf787f diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index bd8a10e8f2..f4b2d9c091 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -27,6 +27,10 @@ English | [中文](README.zh.md) Load the plugin through the web-app manifest; it renders nothing until the session can see at least one job, so an ordinary conversation never grows a control for a capability it is not using. +The header shows the live job count when any jobs are running or stopping, otherwise the visible job count, without repeating the spinner's running state in visible text. The trigger's accessible description identifies live work in both wide and compact layouts. In wide layout, a 14px icon slot shows the spinner for live jobs or the 14px layers icon for idle jobs; the root trigger has no disclosure arrow. At container widths up to 460px, the count hides and the trigger always shows the layers icon, including while jobs are live; running indicators remain inside the menu. The trigger matches More and sidebar controls: a 28×28px icon button with no padding, a 15px glyph in a 15px slot, secondary ink, and ghost hover and active backgrounds. Header actions use an 8px gap in compact layout and 10px in wide layout. Its count tooltip appears after 500ms of hover or immediately on keyboard focus, and stays hidden while the list is open. + +Non-touch hover on the text-visible trigger opens the list after 150ms; leaving both the trigger and its portaled list closes it after 120ms. Clicking the wide trigger opens or pins the list; clicking an already pinned trigger closes it. The compact trigger toggles on click and never opens on hover, including when the count hides during a pending hover. ArrowDown opens and pins the list and focuses its first control. Pointer interaction or keyboard focus inside also pins it. Outside click or Escape dismisses it, and Tab connects the trigger to the portaled controls. Hover timers and pins reset when the session changes, no visible jobs remain, or the component unmounts. + ### One row per job The `job.list` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 62201bccee..f1b99059af 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -27,6 +27,10 @@ kind: "package-reference" 通过 web-app 清单加载本插件;在会话看得到至少一个 job 之前它不渲染任何东西,因此普通对话不会为未使用的能力长出控件。 +有运行中或正在停止的任务时,头部显示这些任务的数量;否则显示可见任务总数,可见文字不重复旋转指示器已表达的运行状态。宽布局和紧凑布局中的触发器均通过无障碍描述说明是否有进行中的任务。宽布局下,14px 图标区域在有进行中任务时显示旋转指示器,否则显示 14px 图层图标;根触发器不带展开箭头。容器宽度不超过 460px 时,数量文字隐藏,触发器始终显示图层图标,包括任务运行期间;运行指示器仍在菜单内显示。触发器与更多及侧边栏控件一致:28×28px 图标按钮,无内边距,15px 图标位内显示 15px 图标,使用次级前景色以及 ghost 悬停和按下背景。页头操作在紧凑布局中间距为 8px,宽布局中为 10px。数量工具提示在悬停 500ms 后或获得键盘焦点时立即显示,列表打开期间保持隐藏。 + +非触摸指针在文字可见的触发器上悬停 150ms 后打开列表;指针离开触发器和挂载到 portal 的列表后,经过 120ms 关闭。点击宽布局触发器会打开或固定列表;再次点击已固定的触发器会关闭列表。紧凑触发器通过点击切换开关状态,不因悬停打开,包括等待悬停期间数量文字隐藏的情况。ArrowDown 会打开并固定列表,将焦点移至首个控件。在列表内进行指针交互或移入键盘焦点也会固定列表。点击外部或按 Escape 可关闭列表,Tab 将触发器与 portal 内的控件连接起来。切换会话、没有可见任务或组件卸载时,悬停计时器与固定状态都会重置。 + ### 一 job 一行 `ctx.jobs` 镜像的 `job.list` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。 diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index a9eedb8079..4283427e63 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -1,7 +1,7 @@ .trigger { display: inline-flex; align-items: center; - gap: 3px; + gap: 4px; min-height: 28px; padding: 3px 2px; border: 0; @@ -23,20 +23,61 @@ } } -.trigger svg { - transition: transform 120ms ease; -} - -.triggerOpen { - transform: rotate(180deg); -} - -.triggerDot { +.activitySlot { + display: inline-flex; flex: none; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; } -.count { - margin: 0 5px; +.trigger .compactIcon { + display: none; +} + +@container (max-width: 460px) { + .trigger .compactIcon { + display: block; + } + + .trigger .textModeActivity { + display: none; + } + + .trigger .count { + display: none; + } + + .trigger { + box-sizing: border-box; + width: 28px; + height: 28px; + padding: 0; + justify-content: center; + color: var(--dsw-alias-label-secondary); + } + + .trigger .activitySlot, + .trigger .activitySlot > svg { + width: 15px; + height: 15px; + } + + .trigger:focus-visible { + color: var(--dsw-alias-label-secondary); + } + + .trigger:active { + background: var(--dsw-alias-interactive-bg-active); + } + + @media (hover: hover) { + .trigger:hover { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); + } + } } /* Portaled and fixed in the viewport with coordinates supplied inline from the diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 7027483b96..c985af6fef 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from 'react' import { createPortal } from 'react-dom' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, + IconChevronDownOutlineRegular, IconLayersOutlineRegular, IconStopFillRegular, MenuSurface, StateDot, TerminalBlock, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -329,15 +329,25 @@ function JobItem({ job, view, expanded, now, onToggle, kill, t }: { * or a settled one with retained output) starts its observation stream, and * collapsing (or closing the popover) stops it — output only flows while * someone is watching. A running row carries a two-press stop button that - * requests a human kill through the job controller. + * requests a human kill through the job controller. Non-touch hover on a + * text-visible trigger opens after 150ms and closes after 120ms outside both + * trigger and portal. Compact triggers toggle on click; ArrowDown opens and + * pins either layout. Interacting inside also pins the menu until explicit + * dismissal; timers and the pin are local to the mounted session. * @param props - runtime slot currency, the jobs snapshot hook, the roster, * observation, and kill controls, and the namespace translator. * @returns the trigger and its popover list, or null when there is nothing to show. */ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, t }: JobListActionProps) { + const activityId = useId() const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS const observedViews = useJobs(state => state.observed) - const [open, setOpen] = useState(false) + const [menuState, setMenuState] = useState<{ sessionId: SessionId; mode: 'closed' | 'hover' | 'pinned' }>( + { sessionId, mode: 'closed' }, + ) + const hoverOpenTimer = useRef | undefined>(undefined) + const hoverCloseTimer = useRef | undefined>(undefined) + if (menuState.sessionId !== sessionId) setMenuState({ sessionId, mode: 'closed' }) const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) // Settled-section fold: an explicit user toggle wins; before one, the tail @@ -350,6 +360,7 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) + const countRef = useRef(null) const menuRef = useRef(null) const rows = useMemo(() => ordered(jobs), [jobs]) @@ -360,6 +371,50 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, ) const settledExpanded = settledOpen ?? liveRows.length === 0 const visibleCount = liveRows.length + settledRows.length + const hasJobs = visibleCount > 0 + const open = hasJobs && menuState.sessionId === sessionId && menuState.mode !== 'closed' + + const cancelHover = useCallback((): void => { + clearTimeout(hoverOpenTimer.current) + clearTimeout(hoverCloseTimer.current) + hoverOpenTimer.current = undefined + hoverCloseTimer.current = undefined + }, []) + + const changeOpen = useCallback((next: boolean): void => { + cancelHover() + if (next) setNow(Date.now()) + setMenuState({ sessionId, mode: next ? 'pinned' : 'closed' }) + }, [cancelHover, sessionId]) + + // CSS owns the compact breakpoint; read its result only for pointer gestures. + const allowsHoverOpen = (): boolean => countRef.current !== null + && getComputedStyle(countRef.current).display !== 'none' + + const hoverEnter = (event: PointerEvent): void => { + if (event.pointerType === 'touch') return + cancelHover() + if (open || !allowsHoverOpen()) return + hoverOpenTimer.current = setTimeout(() => { + hoverOpenTimer.current = undefined + if (!allowsHoverOpen()) return + setNow(Date.now()) + setMenuState({ sessionId, mode: 'hover' }) + }, 150) + } + + const hoverLeave = (event: PointerEvent): void => { + if (event.pointerType === 'touch') return + cancelHover() + if (!open || menuState.mode === 'pinned') return + hoverCloseTimer.current = setTimeout(() => { + hoverCloseTimer.current = undefined + setMenuState({ sessionId, mode: 'closed' }) + }, 120) + } + + // Timers belong to this mounted session and cannot survive a disappearing trigger. + useEffect(() => cancelHover, [cancelHover, sessionId, hasJobs]) // The popover portals to the document body: the session header sits in a // clipping column that the right sidebar borders or overlaps, so an in-place @@ -367,7 +422,18 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, const menuPosition = useAnchoredPosition({ open, anchorRef: rootRef, panelRef: menuRef, gap: MENU_GAP, margin: VIEWPORT_MARGIN, }) - useDismissOnOutsidePointer(rootRef, open, setOpen, menuRef) + useDismissOnOutsidePointer(rootRef, open, changeOpen, menuRef) + // Hover does not move focus; Escape must also work from the rest of the page. + useEffect(() => { + if (!open) return + const dismiss = (event: globalThis.KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) return + event.preventDefault() + changeOpen(false) + } + document.addEventListener('keydown', dismiss) + return () => { document.removeEventListener('keydown', dismiss) } + }, [open, changeOpen]) // The roster follows the mounted session: one stream while this control // lives, released with it. @@ -392,11 +458,10 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, return observe(sessionId, activeJob) }, [sessionId, activeJob, observe]) - // The last visible job disappearing removes this control; close first so - // focus does not vanish from an unmounting node. + // A returning roster starts closed rather than restoring an obsolete pin. useEffect(() => { - if (visibleCount === 0 && open) setOpen(false) - }, [visibleCount, open]) + if (!hasJobs) changeOpen(false) + }, [hasJobs, changeOpen]) // An expanded row that left the list (owner disposal) folds its panel. useEffect(() => { @@ -448,10 +513,8 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, if (visibleCount === 0) return null - const countKey = liveRows.length > 0 - ? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other') - : (visibleCount === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : visibleCount }) + const count = liveRows.length > 0 ? liveRows.length : visibleCount + const countLabel = t(count === 1 ? 'count.idle.one' : 'count.idle.other', { count }) const clearSettled = (): void => { setClearedKeys((current) => { @@ -468,14 +531,14 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, // keep the in-place order: the trigger leads into the list, and leaving // either end of the list returns to the trigger's position in the page. const onKeyDown = (event: KeyboardEvent): void => { - if (!open) return const trigger = triggerRef.current if (event.key === 'Escape') { event.preventDefault() - setOpen(false) + changeOpen(false) trigger?.focus() return } + if (!open) return const menu = menuRef.current /* v8 ignore next -- both refs are attached while the open list renders. */ if (event.key !== 'Tab' || trigger === null || menu === null) return @@ -517,28 +580,43 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, return (
    - + + + + {liveRows.length > 0 && } {open ? createPortal( - + { changeOpen(true) }} + onFocusCapture={() => { changeOpen(true) }} + >
      {liveRows.length > 0 ? diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index 700009d9a5..c968a08b5d 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -5,8 +5,6 @@ export const NS = 'job' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'count.live.one': '{count} 个后台任务运行中', - 'count.live.other': '{count} 个后台任务运行中', 'count.idle.one': '{count} 个后台任务', 'count.idle.other': '{count} 个后台任务', 'list.aria': '后台任务', @@ -49,8 +47,6 @@ export const zh = { /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { - 'count.live.one': '{count} background job running', - 'count.live.other': '{count} background jobs running', 'count.idle.one': '{count} background job', 'count.idle.other': '{count} background jobs', 'list.aria': 'Background jobs', @@ -96,8 +92,6 @@ export type JobKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { - 'count.live.one': 'バックグラウンドジョブ {count} 件が実行中', - 'count.live.other': 'バックグラウンドジョブ {count} 件が実行中', 'count.idle.one': 'バックグラウンドジョブ {count} 件', 'count.idle.other': 'バックグラウンドジョブ {count} 件', 'list.aria': 'バックグラウンドジョブ', @@ -140,8 +134,6 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - 'count.live.one': 'Tareas en segundo plano en ejecución: {count}', - 'count.live.other': 'Tareas en segundo plano en ejecución: {count}', 'count.idle.one': 'Tareas en segundo plano: {count}', 'count.idle.other': 'Tareas en segundo plano: {count}', 'list.aria': 'Trabajos en segundo plano', @@ -184,8 +176,6 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - 'count.live.one': 'Tâches en arrière-plan en cours : {count}', - 'count.live.other': 'Tâches en arrière-plan en cours : {count}', 'count.idle.one': 'Tâches en arrière-plan : {count}', 'count.idle.other': 'Tâches en arrière-plan : {count}', 'list.aria': 'Travaux en arrière-plan', @@ -228,8 +218,6 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - 'count.live.one': 'Laufende Hintergrundaufgaben: {count}', - 'count.live.other': 'Laufende Hintergrundaufgaben: {count}', 'count.idle.one': 'Hintergrundaufgaben: {count}', 'count.idle.other': 'Hintergrundaufgaben: {count}', 'list.aria': 'Hintergrundjobs', @@ -272,8 +260,6 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - 'count.live.one': 'Фоновые задачи выполняются: {count}', - 'count.live.other': 'Фоновые задачи выполняются: {count}', 'count.idle.one': 'Фоновые задачи: {count}', 'count.idle.other': 'Фоновые задачи: {count}', 'list.aria': 'Фоновые задания', @@ -316,8 +302,6 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - 'count.live.one': 'Tarefas em segundo plano em execução: {count}', - 'count.live.other': 'Tarefas em segundo plano em execução: {count}', 'count.idle.one': 'Tarefas em segundo plano: {count}', 'count.idle.other': 'Tarefas em segundo plano: {count}', 'list.aria': 'Trabalhos em segundo plano', diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index d3a4c72053..c8ee1a0e6a 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -5,7 +5,23 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' -import { zh, ru } from '../src/client/locales.ts' +import { zh, en, ja, es, fr, de, ru, pt } from '../src/client/locales.ts' + +beforeEach(() => { + class TooltipObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const size = { inlineSize: 160, blockSize: 30 } + this.callback([{ + target, borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], contentRect: new DOMRect(), + }], this) + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', TooltipObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) +}) beforeEach(() => { vi.stubGlobal('CSS', { supports: (property: string) => property === '-webkit-backdrop-filter' }) @@ -57,6 +73,28 @@ function openList(): void { fireEvent.click(screen.getAllByRole('button')[0]!) } +describe('JobListAction count copy', () => { + it.each([ + [zh, '1 个后台任务', '2 个后台任务'], + [en, '1 background job', '2 background jobs'], + [ja, 'バックグラウンドジョブ 1 件', 'バックグラウンドジョブ 2 件'], + [es, 'Tareas en segundo plano: 1', 'Tareas en segundo plano: 2'], + [fr, 'Tâches en arrière-plan : 1', 'Tâches en arrière-plan : 2'], + [de, 'Hintergrundaufgaben: 1', 'Hintergrundaufgaben: 2'], + [ru, 'Фоновые задачи: 1', 'Фоновые задачи: 2'], + [pt, 'Tarefas em segundo plano: 1', 'Tarefas em segundo plano: 2'], + ] as const)('uses neutral count copy for live and settled jobs in locale %#', (dictionary, one, two) => { + const translate: JobListActionProps['t'] = makeTranslate(dictionary) + const settled = job({ id: 'settled' as JobView['id'], status: 'completed', finishedAt: 1_700_000_003_000 }) + const { rerender } = render() + expect(screen.getByRole('button', { name: one, description: dictionary['section.live'] })).toBeDefined() + rerender() + expect(screen.getByRole('button', { name: one, description: dictionary['section.live'] })).toBeDefined() + rerender() + expect(screen.getByRole('button', { name: two, description: '' })).toBeDefined() + }) +}) + describe('JobListAction visibility', () => { it('renders nothing while the session sees no jobs', () => { const { container } = render() @@ -78,7 +116,24 @@ describe('JobListAction visibility', () => { outputJob(), job({ id: 'subagent-1' as JobView['id'], kind: 'subagent', label: 'explore' }), ])} />) - expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() + expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() + }) + + it('retains the compact Layers icon through running, stopping, and idle states without a root chevron', () => { + const { rerender } = render() + const trigger = screen.getByRole('button') + expect(trigger.querySelector('[data-icon="dsw-icon-layers-outline-regular"]')).not.toBeNull() + expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(trigger.querySelector('[data-icon="dsw-icon-chevron-down-outline-regular"]')).toBeNull() + rerender() + expect(screen.getByRole('button')).toBe(trigger) + expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() + rerender() + expect(screen.getByRole('button')).toBe(trigger) + expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull() + const icon = trigger.querySelector('[data-icon="dsw-icon-layers-outline-regular"]') + expect(icon?.getAttribute('width')).toBe('14') + expect(trigger.querySelectorAll('svg')).toHaveLength(1) }) it('falls back to the total when nothing is live', () => { @@ -89,6 +144,281 @@ describe('JobListAction visibility', () => { }) }) +describe('JobListAction hover', () => { + function pointer(target: Element, type: 'pointerover' | 'pointerout', pointerType = 'mouse'): void { + // jsdom lacks PointerEvent; preserve its pointer type on the native event. + const event = new MouseEvent(type, { bubbles: true }) + Object.defineProperty(event, 'pointerType', { value: pointerType }) + fireEvent(target, event) + } + + function advance(ms: number): void { + act(() => { vi.advanceTimersByTime(ms) }) + } + + function hoverOpen(): HTMLElement { + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) + pointer(trigger, 'pointerover') + advance(149) + expect(screen.queryByRole('list')).toBeNull() + advance(1) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + return trigger + } + + it('keeps the compact trigger click-only and shows its count tooltip after 500ms', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + const count = trigger.querySelector('[data-job-count]')! + count.style.display = 'none' + pointer(trigger, 'pointerover') + advance(499) + expect(screen.queryByRole('list')).toBeNull() + expect(screen.queryByRole('tooltip')).toBeNull() + advance(1) + expect(screen.getByRole('tooltip').textContent).toBe('1 个后台任务') + fireEvent.click(trigger) + expect(screen.queryByRole('tooltip')).toBeNull() + pointer(trigger, 'pointerout') + advance(500) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('cancels pending wide hover when CSS hides the count and compact clicks close hover-opened lists', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + const count = trigger.querySelector('[data-job-count]')! + pointer(trigger, 'pointerover') + advance(100) + count.style.display = 'none' + advance(50) + expect(screen.queryByRole('list')).toBeNull() + pointer(trigger, 'pointerout') + count.style.display = '' + hoverOpen() + count.style.display = 'none' + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('shows count on keyboard focus immediately and opens and pins with ArrowDown', async () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + trigger.querySelector('[data-job-count]')!.style.display = 'none' + fireEvent.keyDown(document.body, { key: 'Tab' }) + act(() => { trigger.focus() }) + expect(screen.getByRole('tooltip').textContent).toBe('1 个后台任务') + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + await act(async () => { vi.runAllTicks() }) + expect(screen.queryByRole('tooltip')).toBeNull() + const row = screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }) + expect(document.activeElement).toBe(row) + pointer(trigger, 'pointerout') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByRole('list')).toBeNull() + expect(document.activeElement).toBe(trigger) + }) + + it('waits for hover, bridges the portal gap, and closes after leaving both surfaces', () => { + vi.useFakeTimers() + const observe = vi.fn(() => () => {}) + const { container } = render() + const trigger = hoverOpen() + const menu = screen.getByRole('list').parentElement! + expect(container.contains(menu)).toBe(false) + expect(observe).not.toHaveBeenCalled() + pointer(trigger, 'pointerout') + advance(119) + pointer(menu, 'pointerover') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + pointer(menu, 'pointerout') + advance(119) + expect(screen.getByRole('list')).toBeDefined() + advance(1) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('cancels short hover and preserves a returning pointer during leave grace', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + pointer(trigger, 'pointerover') + advance(149) + pointer(trigger, 'pointerout') + advance(150) + expect(screen.queryByRole('list')).toBeNull() + hoverOpen() + pointer(trigger, 'pointerout') + advance(100) + pointer(trigger, 'pointerover') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + }) + + it('pins a hover-opened list on click until explicit dismissal', () => { + vi.useFakeTimers() + render() + const trigger = hoverOpen() + fireEvent.click(trigger) + pointer(trigger, 'pointerout') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + hoverOpen() + fireEvent.click(trigger) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('list')).toBeNull() + hoverOpen() + pointer(trigger, 'pointerout') + advance(120) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('ignores touch hover while keeping tap usable', () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button') + pointer(trigger, 'pointerover', 'touch') + advance(150) + expect(screen.queryByRole('list')).toBeNull() + fireEvent.click(trigger) + pointer(trigger, 'pointerout', 'touch') + advance(120) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.click(trigger) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('pins keyboard focus and releases expanded output on Escape or hover close', () => { + vi.useFakeTimers() + const stop = vi.fn() + const observe = vi.fn(() => stop) + render() + const trigger = hoverOpen() + pointer(trigger, 'pointerout') + act(() => { trigger.focus() }) + fireEvent.keyDown(trigger, { key: 'Tab' }) + const row = screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }) + expect(document.activeElement).toBe(row) + advance(120) + fireEvent.click(row) + expect(observe).toHaveBeenCalledTimes(1) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(document.activeElement).toBe(trigger) + expect(stop).toHaveBeenCalledTimes(1) + hoverOpen() + expect(observe).toHaveBeenCalledTimes(2) + pointer(trigger, 'pointerout') + advance(120) + expect(screen.queryByRole('list')).toBeNull() + expect(stop).toHaveBeenCalledTimes(2) + }) + + it('pins pointer interaction so leaving cannot interrupt stop confirmation', () => { + vi.useFakeTimers() + render() + const trigger = hoverOpen() + pointer(trigger, 'pointerout') + const stop = screen.getByTitle(zh['kill.stop'].replace('{label}', 'pnpm run build')) + fireEvent.pointerDown(stop) + fireEvent.click(stop) + pointer(screen.getByRole('list').parentElement!, 'pointerout') + advance(120) + expect(stop.getAttribute('data-kill-state')).toBe('armed') + expect(screen.getByRole('list')).toBeDefined() + }) + + it('dismisses a hover-opened list with Escape while focus is elsewhere', () => { + vi.useFakeTimers() + render() + hoverOpen() + fireEvent.keyDown(document.body, { key: 'a' }) + expect(screen.getByRole('list')).toBeDefined() + fireEvent.keyDown(document.body, { key: 'Escape' }) + expect(screen.queryByRole('list')).toBeNull() + }) + + it.each(['opening', 'closing'] as const)('cleans up a pending %s timer on unmount', (phase) => { + vi.useFakeTimers() + const { unmount } = render() + const trigger = screen.getByRole('button') + pointer(trigger, 'pointerover') + if (phase === 'closing') { + advance(150) + pointer(trigger, 'pointerout') + } + unmount() + expect(vi.getTimerCount()).toBe(0) + advance(150) + expect(screen.queryByRole('list')).toBeNull() + }) + + it('releases expanded output and pending hover close before showing another session', () => { + vi.useFakeTimers() + const stop = vi.fn() + const observe = vi.fn(() => stop) + const first = props([outputJob()], observe) + const { rerender } = render() + const trigger = hoverOpen() + const row = screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }) + fireEvent.click(row) + expect(observe).toHaveBeenCalledTimes(1) + pointer(trigger, 'pointerout') + const nextId = 'next-session' as SessionId + rerender( select({ rows: { [nextId]: [outputJob()] }, observed: {} })} + />) + expect(stop).toHaveBeenCalledTimes(1) + expect(observe).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(0) + hoverOpen() + advance(120) + expect(screen.getByRole('list')).toBeDefined() + expect(observe).toHaveBeenLastCalledWith(nextId, 'bash-1') + }) + + it('cancels pending hover and clears pins on session change or an empty roster', () => { + vi.useFakeTimers() + const first = props([job()]) + const nextId = 'next-session' as SessionId + const next: JobListActionProps = { + ...first, + sessionId: nextId, + useJobs: select => select({ rows: { [nextId]: [job()] }, observed: {} }), + } + const { rerender } = render() + pointer(screen.getByRole('button'), 'pointerover') + rerender() + advance(150) + expect(screen.queryByRole('list')).toBeNull() + const trigger = hoverOpen() + fireEvent.click(trigger) + rerender() + expect(screen.queryByRole('list')).toBeNull() + pointer(screen.getByRole('button'), 'pointerover') + rerender() + advance(150) + expect(vi.getTimerCount()).toBe(0) + rerender() + hoverOpen() + fireEvent.click(screen.getByRole('button', { name: '1 个后台任务' })) + rerender() + rerender() + expect(screen.queryByRole('list')).toBeNull() + }) +}) + describe('JobListAction rows', () => { it('renders a settled job without retained output as a static row', () => { render( { const settled = [job({ id: 'bash-2' as JobView['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })] render() openList() - expect(screen.getByText(zh['section.live'])).toBeDefined() + expect(screen.getByText(zh['section.live'], { selector: 'li' })).toBeDefined() const toggle = screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }) // Folded by default while something runs; the toggle expands it. expect(toggle.getAttribute('aria-expanded')).toBe('false') @@ -293,7 +623,7 @@ describe('JobListAction observation', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) const { container } = render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) const anchor = vi.spyOn(trigger.parentElement!, 'getBoundingClientRect') .mockReturnValue(new DOMRect(344, 40, 180, 28)) fireEvent.click(trigger) @@ -319,7 +649,7 @@ describe('JobListAction observation', () => { vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(600) vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(120) render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(4, 540, 180, 28)) fireEvent.click(trigger) const menu = screen.getByRole('list', { name: zh['list.aria'] }) @@ -335,7 +665,7 @@ describe('JobListAction observation', () => { vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80) const remove = vi.spyOn(viewport, 'removeEventListener') render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) vi.spyOn(trigger.parentElement!, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 300, 200, 20)) fireEvent.click(trigger) const menu = screen.getByRole('list', { name: zh['list.aria'] }).closest('[data-menu-material]')! @@ -362,7 +692,7 @@ describe('JobListAction observation', () => { const stop = vi.fn() const observe = vi.fn(() => stop) const { container, unmount } = render() - const trigger = screen.getByRole('button', { name: '1 个后台任务运行中' }) + const trigger = screen.getByRole('button', { name: '1 个后台任务' }) trigger.focus() fireEvent.click(trigger) const list = screen.getByRole('list', { name: zh['list.aria'] }) @@ -629,7 +959,7 @@ it.each([1, 2, 5, 21])('renders count-independent Russian labels for %i jobs', ( const jobs = Array.from({ length: count }, (_, i) => job({ id: `job-${i}` as JobView['id'] })) const translate: JobListActionProps['t'] = makeTranslate(ru) const view = render() - expect(screen.getByRole('button', { name: `Фоновые задачи выполняются: ${count}` })).toBeDefined() + expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined() view.rerender( ({ ...item, status: 'completed', finishedAt: 1_700_000_003_000 })))} t={translate} />) expect(screen.getByRole('button', { name: `Фоновые задачи: ${count}` })).toBeDefined() }) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 3b307be019..b4bb58e659 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -27,8 +27,8 @@ en: 7f20428c747ff052 zh: 975e6de728e02fa0 /deepseek-ai-dsh-client-ui-model-selection/use-this-package/selection-failures: - en: 261bbf42296243af - zh: 72779fb7b8fbc473 + en: 80fc237fa5c829f7 + zh: ecff5c8526f45996 /deepseek-ai-dsh-client-ui-model-selection/understand-the-implementation: en: 14870a55b8f4d4b7 zh: f498b469a785271e diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index dbffa12fef..1a53aa7c7b 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -61,6 +61,8 @@ Catalog availability does not block sending with a saved selection; request exec When another writer owns the Session, model-selection failures tell the user to quit other running DSH instances and retry. +The session-scoped `conversation.input.model.options` list slot appends controls below model and reasoning selection. Owner props expose `locked`, `reportFailure(message)`, and `dismiss()`, which closes the menu and synchronously focuses its trigger before another overlay opens; contributions read their own projections and supply narrow commands. Enabled switch buttons participate in the menu’s keyboard navigation. + ----- diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 49c1d7ea7c..253370b97f 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -61,6 +61,8 @@ composer 菜单中的模型名称和推理等级均使用 400(regular)字重 当会话被其他写句柄占用时,模型选择失败提示用户退出其他正在运行的 DSH 后重试。 +会话级列表 slot `conversation.input.model.options` 在模型与推理等级选项下方追加控件。Owner props 提供 `locked`、`reportFailure(message)` 和 `dismiss()`,后者关闭菜单并同步聚焦触发按钮,供贡献方随后打开其他浮层;贡献方读取自身投影并提供专用命令。启用的开关按钮参与菜单键盘导航。 + ----- diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index ecc984ca76..0369578a08 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -66,7 +66,7 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } export function ModelSelect( { locked, available, directory, load, select, resolveText, t, renderSlot }: ModelSelectInjected & Pick, 'locked'> & PropsLocale<'model'> - & Omit, 'SessionProvider'>, + & Omit, 'SessionProvider'>, ) { const state = useSyncExternalStore( fn => directory.subscribe(fn), @@ -267,7 +267,9 @@ export function ModelSelect( } const moveFocus = (offset: number): void => { - const items = itemRefs.current.filter(item => item !== null) + const items = Array.from(menuRef.current?.querySelectorAll( + 'button[role="menuitem"]:not([disabled]), button[role="menuitemradio"]:not([disabled]), button[role="switch"]:not([disabled])', + ) ?? []) if (items.length === 0) return const active = items.findIndex(item => item === document.activeElement) // Focus outside the rows (the trigger, which keeps it while the menu @@ -321,7 +323,9 @@ export function ModelSelect( // control inside the card (a retry button) keeps the browser's traversal, // so the keystroke stays unconsumed there. const focused = document.activeElement - const rows = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null) + const rows = Array.from(menuRef.current?.querySelectorAll( + 'button[role="menuitem"]:not([disabled]), button[role="menuitemradio"]:not([disabled]), button[role="switch"]:not([disabled])', + ) ?? []) if (focused instanceof HTMLButtonElement && rows.includes(focused)) { event.preventDefault() focused.click() @@ -507,6 +511,14 @@ export function ModelSelect( )} + {renderSlot('conversation.input.model.options', { + locked: locked || busy, + dismiss: () => { + close() + triggerRef.current?.focus() + }, + reportFailure: (message) => { setToast({ seq: ++toastSeq.current, text: message }) }, + })} )} diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index b91ee20b48..224990e848 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -174,6 +174,7 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true }, + 'conversation.input.model.options': { kind: 'list', scope: 'session' }, }, inject: (sessionId): ModelSelectInjected => { const directory = models.directoryFor(sessionId) diff --git a/packages/client/ui-model-selection/src/client/slots.ts b/packages/client/ui-model-selection/src/client/slots.ts index 4ffddee580..39a31b673f 100644 --- a/packages/client/ui-model-selection/src/client/slots.ts +++ b/packages/client/ui-model-selection/src/client/slots.ts @@ -19,6 +19,19 @@ export interface ModelLabelOwner { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { + /** Session options appended after model and reasoning controls in the root menu. */ + 'conversation.input.model.options': { + kind: 'list' + scope: 'session' + owner: { + /** Whether the composer currently refuses selection changes. */ + locked: boolean + /** Close the menu and focus its trigger synchronously before opening another overlay. */ + dismiss: () => void + /** Report a failed selection through the menu owner's persistent toast. */ + reportFailure: (message: string) => void + } + } /** * Non-interactive inline model label; selection controls remain owner-managed. * Without a contribution, the badge precedes the catalog name in the trigger diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index dbb5b247db..2807820dc5 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -7,6 +7,7 @@ import { SessionId } from '@deepseek-ai/dsh-session/types' import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ComponentProps } from 'react' +import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelDirectoryState } from '../src/client/directory.ts' import { ModelSelect } from '../src/client/ModelSelect.tsx' import { en, zh } from '../src/client/locales.ts' @@ -98,6 +99,7 @@ describe('ModelSelect label slot', () => { }) await runtime.root.declare({ 'conversation.input.model.label': { kind: 'single', scope: 'session', inline: true }, + 'conversation.input.model.options': { kind: 'list', scope: 'session' }, }, ({ renderSlot, SessionProvider }) => @@ -1121,3 +1123,20 @@ it('restores the account model name after login without changing the saved route expect(screen.getByRole('button', { name: /选择模型,当前/ }).textContent).toBe('DeepSeek FlashHigh') expect(directory.getSnapshot().current).toEqual(selected) }) + + +it('dismisses contributed options with focus on the trigger before opening another overlay', () => { + const renderSlot = vi.fn((_name: string, _owner: object) => null) + render() + const trigger = screen.getByRole('button', { name: /^选择模型/ }) + fireEvent.click(trigger) + const options = renderSlot.mock.calls.find(call => call[0] === 'conversation.input.model.options') + if (options?.[0] !== 'conversation.input.model.options') throw new Error('Missing model options') + act(() => { + screen.getAllByRole('menuitem')[0]!.focus() + ;(options[1] as OwnerOf<'conversation.input.model.options'>).dismiss() + expect(document.activeElement).toBe(trigger) + }) + expect(screen.queryByRole('menu')).toBeNull() +}) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 781b5d2a7c..4eea9e69b2 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 07da57fb0a0cb10e - zh: c743fd96317bec22 + en: 5a06147f2a79eccf + zh: 6ca5899680e3cc6f /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: cd4e17d62c407541 zh: 9610d3ee55c0e56a diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 03ed8e8c24..5d71122e31 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -39,7 +39,7 @@ Installed bundles and their plugin rows display their own title and description A bundle's page shows the bundle's detail document first among its sections, without a heading of its own: the bundle's long description in the current UI language, as Markdown with images. It is the Markdown file that `meta.detail` in the bundle's `locale/.json` names, otherwise the bundle's README, read by [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.md#bundle-detail-documents) in the [document order of `dsh-plugin-detail`](../../util/plugin-detail/README.md#document-order); the repository a bundle declares is not read. An installed bundle's document comes from its own files without network access, and an on-demand Official bundle's from the npm file CDNs at its offered version. While the document loads the section shows three skeleton lines; a bundle without a document shows no section; a failed read names its reason with **Retry**. Raw HTML is omitted, relative images load from the Host beside the document, relative links stay text, and a document taller than the section's height limit scrolls inside it. A bundle whose files are not installed, such as an on-demand Official bundle, is read from the npm file CDNs: its read first asks the Host's registry comparison and, when npmmirror answered fastest, reads it and its images from npmmirror first. An installed bundle's read does not wait for the comparison. Any profile change and a connection reset read shown documents again. -On-demand Official cards follow package discovery visibility and are available offline before installation. In Add plugin, a bare Official package name uses the Host’s matching-version or source-checkout target, retains the selected registry, and offers Enable now after installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. +On-demand Official cards are visible offline before installation. In Add plugin, a bare Official package name uses the Host’s matching-version or source-checkout target, retains the selected registry, and offers Enable now after installation. Turning one On opens the existing install progress dialog, installs the [target offered by the running Host](../../boot/plugin-manager/README.md#use-this-package), and selects its layer. The same registry fallback, cancellation, and script-approval controls apply. Turning Off keeps the dependency installed; Remove is separate. A different version or development link source offers **Update**; details show current and offered sources and versions. An update removes the installed package and installs the offered one with the same selection, reporting whether a restart is required; when the installation fails after the removal, the package stays uninstalled. Missing or unreadable packages keep their recorded dependency source alongside the diagnostic. An enabled bundle can still be switched Off when its checkout target is unavailable. The first list read shows a short group-heading placeholder and four skeleton cards, using the real cards' row, icon, and text-line spacing, with the action area left blank. They use the same opacity pulse as Session search and remain static with reduced motion. The accessible loading label and read timing are unchanged; refreshes retain the existing list, and installation and component-loading indicators keep their own states. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index b0dedeaad9..5978dc9d0e 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" 组合包页面在其各部分中首先显示该组合包的详细介绍文档,不带单独的标题:以当前界面语言、带图片的 Markdown 显示该组合包的详细介绍。该文档是组合包 `locale/.json` 中 `meta.detail` 指定的 Markdown 文件,没有时为组合包的 README,由 [`pluginManager.readBundleDetail`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 按 [`dsh-plugin-detail` 的文档顺序](../../util/plugin-detail/README.zh.md#document-order)读取;不读取组合包声明的仓库。已安装组合包的文档来自其自身文件,不访问网络;按需官方组合包的文档按其提供的版本从 npm 文件 CDN 读取。文档加载期间,该部分显示三行骨架屏;没有文档的组合包不显示该部分;读取失败时说明原因,并提供**重试**。原始 HTML 会被省略,相对路径的图片从 Host 上该文档旁的位置加载,相对链接保持为文本,文档高度超过该部分的高度上限时在该部分内滚动。文件未安装的组合包(例如按需官方组合包)从 npm 文件 CDN 读取:读取前先询问 Host 的注册表比较,npmmirror 响应最快时,先从 npmmirror 读取该文档及其图片。已安装组合包的读取不等待该比较。任何 profile 变化和连接重置都会重新读取已显示的文档。 -官方按需卡片遵循包的显示声明,在安装前即可离线读取。在“添加插件”中输入不带版本的官方包名时,使用 Host 提供的匹配版本或源码 checkout 目标,保留用户选择的安装源,并在安装后提供“立即启用”。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 +官方按需卡片在安装前即可离线显示。在“添加插件”中输入不带版本的官方包名时,使用 Host 提供的匹配版本或源码 checkout 目标,保留用户选择的安装源,并在安装后提供“立即启用”。开启会打开已有的安装进度对话框,安装[当前 Host 提供的目标](../../boot/plugin-manager/README.zh.md#use-this-package)并选择配置层。安装源回退、取消和脚本审批继续使用同一组控件。关闭保留依赖;移除是独立操作。版本或开发链接来源不同会提供**更新**;详情显示当前及所提供的来源和版本。更新先删除已安装的包,再以相同的选择状态安装所提供的包,并报告是否需要重启;删除之后安装失败时,该包保持未安装。文件缺失或不可读的包在诊断旁保留记录的依赖来源。已启用的 bundle 在 checkout 目标不可用时仍可关闭。 首次读取列表时,页面显示一条较短的分组标题占位和四行骨架卡片,沿用真实卡片的行、图标和文字间距,操作区域留白。骨架使用与 Session 搜索相同的透明度呼吸动效,在减少动态效果模式下保持静止。无障碍加载文案和读取时机不变;刷新时保留已有列表,安装及组件加载仍使用各自的状态提示。 diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 29dd9ffd63..79c530ccf4 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -735,14 +735,14 @@ describe('PluginManagerPage', () => { pkg({ name: '@acme/dsh-tool', enabled: false }), // Selected by the profile but not a bundle: a problem the person can switch off, in the profile's own group. pkg({ name: 'dsh-selected', installed: false, error: { code: 'not-bundle' } }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', installed: false, optional: true, removable: false, enabled: false }), ], busy: ['dsh-protected'], }) const cards = screen.getAllByRole('listitem') // Offered extensions precede installed bundles. expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([ - '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', + '@deepseek-ai/dsh-experimental-voice-input-bundle', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', ]) expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem']) // Group titles have no counts; the official bundle retains its experimental marker. @@ -776,11 +776,11 @@ describe('PluginManagerPage', () => { ].map(name => pkg({ name })), pkg({ name: '@acme/dsh-base', readOnlyReason: 'management-required' }), pkg({ name: 'dsh-better-sidebar' }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', installed: false, optional: true, removable: false }), ], }) expect(screen.getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))).toEqual([ - '@deepseek-ai/dsh-experimental-agent-team-profile', '@acme/dsh-base', 'dsh-better-sidebar', + '@deepseek-ai/dsh-experimental-voice-input-bundle', '@acme/dsh-base', 'dsh-better-sidebar', ]) expect(document.querySelector('[data-plugin-count]')).toBeNull() }) @@ -789,7 +789,7 @@ describe('PluginManagerPage', () => { const name = '@deepseek-ai/dsh-experimental-terminal-bundle' const b = renderTab({ packages: [ pkg({ name, official: true, enabled, rows: [row()] }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', official: true }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', official: true }), ] }) expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull() fireEvent.click(screen.getByRole('button', { name: en.moreAction })) @@ -807,7 +807,7 @@ describe('PluginManagerPage', () => { it('keeps a shipped bundle out of both lists until one of its declared rows runs', () => { const hidden = '@deepseek-ai/dsh-experimental-liveshare' - const shown = '@deepseek-ai/dsh-experimental-agent-team-profile' + const shown = '@deepseek-ai/dsh-experimental-voice-input-bundle' const offer = (rows: readonly PackageRow[]): PackageView => pkg({ name: hidden, official: true, installed: false, optional: true, removable: false, enabled: false, listedByDefault: false, rows, }) @@ -881,16 +881,16 @@ describe('PluginManagerPage', () => { }) it('opens an official bundle\'s page with its beta tag and no uninstall, and switches it on', () => { - const title = 'Agent Teams' + const title = 'Voice input' const { actions } = renderTab({ - packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', meta: { title }, installed: false, optional: true, removable: false, enabled: false })], + packages: [pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', meta: { title }, installed: false, optional: true, removable: false, enabled: false })], }) fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', title) })) const detail = document.querySelector('[data-plugin-detail]') as HTMLElement expect(within(detail).getByText(en.statusBeta)).toBeTruthy() expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', title) })).toBeNull() fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', title) })) - expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-voice-input-bundle', true) }) it('keeps the persisted selection while marking a failed component as a problem', () => { @@ -915,7 +915,7 @@ describe('PluginManagerPage', () => { }) it.each([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', true], + ['@deepseek-ai/dsh-experimental-voice-input-bundle', true], ['@deepseek-ai/dsh-experimental-auto-review', true], ['@deepseek-ai/dsh-experimental-fixture-input', false], ['@deepseek-ai/dsh-cua-bundle', true], @@ -1198,11 +1198,11 @@ describe('PluginManagerPage', () => { } it('keeps configuration and installed plugins on the main list and shows eligible official plugins on the secondary page', () => { - const team = '@deepseek-ai/dsh-experimental-agent-team-profile' + const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' renderTab( { packages: [ pkg({ name: '@deepseek-ai/dsh-experimental-badge-skill-bundle', official: true, enabled: false }), - pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', installed: false, optional: true, removable: false, enabled: false }), + pkg({ name: '@deepseek-ai/dsh-experimental-voice-input-bundle', installed: false, optional: true, removable: false, enabled: false }), pkg({ name: '@deepseek-ai/dsh-subagent-claude-code', official: true, availability: 'missing', installed: false, enabled: false }), pkg({ name: '@deepseek-ai/dsh-subagent-codex', official: true, enabled: false }), pkg({ name: '@deepseek-ai/dsh-experimental-tool-worktree', official: true, enabled: false }), @@ -1223,7 +1223,7 @@ describe('PluginManagerPage', () => { expect(within(extensions).getByRole('heading', { name: en.extensionsTitle })).toBeTruthy() expect(within(extensions).getByRole('button', { name: en.moreAction }).textContent).toBe(en.moreAction) expect(within(extensions).getAllByRole('listitem').map(card => card.getAttribute('data-plugin-package'))) - .toEqual(['@deepseek-ai/dsh-experimental-agent-team-profile', '@acme/new-official']) + .toEqual(['@deepseek-ai/dsh-experimental-voice-input-bundle', '@acme/new-official']) expect(document.querySelector('[data-plugin-count]')).toBeNull() expect(within(basic).getByText('Limits every command.')).toBeTruthy() expect(within(basic).queryByRole('switch', { name: en.enableToggle.replace('{name}', 'Shell') })).toBeNull() @@ -1270,7 +1270,6 @@ describe('PluginManagerPage', () => { }) it.each([ - '@deepseek-ai/dsh-cua-bundle', '@deepseek-ai/dsh-experimental-session-search', '@deepseek-ai/dsh-experimental-ralph-bundle', '@acme/shipped-exploration', @@ -1378,7 +1377,7 @@ describe('PluginManagerPage', () => { '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-personalized-recommendations', ])('pins enabled %s onto the main list and removes it when disabled', (first) => { - const team = '@deepseek-ai/dsh-experimental-agent-team-profile' + const team = '@deepseek-ai/dsh-experimental-voice-input-bundle' const second = '@deepseek-ai/dsh-experimental-cot-translation-bundle' const entry = (name: string, enabled: boolean): PackageView => pkg({ name, official: true, enabled }) const b = renderTab({ packages: [entry(second, true), entry(team, true), entry(first, false)] }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 67b95bded7..dd8cb347e7 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1979,10 +1979,10 @@ const CATALOG: BundleInfo = { installTarget: { spec: '@deepseek-ai/dsh-subagent-codex@2.0.0', version: '2.0.0' }, } -it.each(['registry', 'source'] as const)('installs a typed default-hidden Official name using its %s target', async (mode) => { - const name = '@deepseek-ai/dsh-cua-bundle' - const spec = mode === 'source' ? 'link:/workspace/cua-bundle' : `${name}@2.0.0` - const entry = { ...CATALOG, name, listedByDefault: false, installTarget: { spec, version: '2.0.0' } } +it.each(['registry', 'source'] as const)('installs a typed Official catalog name using its %s target', async (mode) => { + const name = CATALOG.name + const spec = mode === 'source' ? 'link:/workspace/subagent-codex' : `${name}@2.0.0` + const entry = { ...CATALOG, installTarget: { spec, version: '2.0.0' } } const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) }) await controller.load() face.openInstall() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6bdac6f1ab..9b127ea88a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,14 +18,14 @@ en: ead6be55c7c9d7cf zh: 125ad36db7328551 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: cfdc59fa1a077ff7 - zh: 4e6dcca712ba6565 + en: 8c99c97e369192cb + zh: f263851ad2a7ba2e /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: 0d756c1e8207181d zh: 921ce997c6b14823 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 3271871a42bd52c8 - zh: 89b7a9f03018454d + en: e2649937f077d4e0 + zh: 6ad604cb5adfc16b /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 81e58c7b342baf3c zh: 6cd47b16124b215c @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 6b4d21406db53e7c - zh: aa130102a8f7cd13 + en: 433ff539bd3d16b3 + zh: fcb37c1fbe503620 /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 1e38950360..1fcc2320df 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -45,7 +45,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. Outlined actions use an inset hairline. Its ref targets the native button for focus and overlay anchoring. | | `Switch` | Two-state toggle, 36×20. The off-state thumb reads `--dsw-alias-switch-thumb` so it stays light in both themes, and the on-state thumb contrasts with the brand track. Disabled controls use half opacity. `label` is required, so the control cannot ship unnamed. | | `SegmentedControl` | Tablist of two or more equal-width segments with one sliding indicator, for switching a card or panel between a few modes; the owner holds the selection and `label` names the list. `id` seeds each tab's id (`-`) and the panel it controls (`--panel`), which the owner renders and points back at the tab with `aria-labelledby`; a segment may be `disabled` with a `title`, and `disabled` on the control locks every segment while the shown panel has work in flight. | -| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | +| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. `modalAutofocus` marks the input for its owning Modal's initial focus without moving focus itself. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | | `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | @@ -58,11 +58,11 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state unless the caller supplies a feature-specific override. Semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. `headerAccessory` places an independent action outside the disclosure target and shimmer; with an accessory, the title row shrinks to its content width. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | -| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | +| `AnimatedRows` | Keyed list movement with 200 ms glides and 100 ms entry/exit fades. Starts after local pointer or keyboard input, honors reduced motion, and settles loading or view replacement. Only rows intersecting both the list and the browser window animate; all rows remain mounted. Mark rows with `data-row-key`, supply matching `rowKeys`, and position the parent for the inert exit overlay; `role` and `label` belong to the caller. | | `useRowDragPreview` | Native drag preview using an inert offscreen row copy. The optional `inheritedProperties` list names the CSS custom properties whose computed values the offscreen wrapper copies; omitting it copies none. Keep the list reference stable, because replacing it clears an active preview. Unlisted custom properties follow normal inheritance at the preview's location. Supply a class for the surface background and, for a child drag handle, pass the row to `start`; `clear` and unmount remove the copy. | | `TextShimmer` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep and a 500ms rest. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `TextShimmer` so only text and separators receive the highlight. A string child uses the same retained text interface; nested `TextShimmer` instances share the outer animation. Composite children render twice while active and must have no effects or element ids. Mark separators with `data-shimmer-decoration` so their fill receives the highlight. `DisclosureRow` forwards `contentClassName` for the text area and `contentLayoutClassName` for its inner layout. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. | -| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. | -| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | +| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. The tint and dialog fade in while backdrop blur stays fully applied, honoring reduced motion. Set `backdropBlur={false}` when the caller already blurs the source page. `tone="warning"` places an amber warning icon in the title and removes the body's extra top margin; other dialogs retain their spacing. | +| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox, using the shared warning-title layout. | | `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | | `DraggableImage` | Original-image gestures with explicit transfer and feedback callbacks. | @@ -115,7 +115,7 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and Explicit `./`, `../`, slash/backslash-rooted paths (including UNC), and Windows drive paths such as `C:/work/file.ts` take precedence over URL parsing. Otherwise, destinations accepted by the absolute URL parser remain URLs: `index.ts:24`, `Makefile:12`, and `localhost:3000` are links, without adding a protocol or testing whether they can open. Use `./index.ts:24` for a file reference. Other destinations are paths. Authored links reject `javascript:`, `vbscript:`, `data:`, and `blob:`; other parseable protocols retain native navigation. HTTP(S) links retain delegated navigation and modified-click behavior. No click-time failure notice is added. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A bare URL ends before non-ASCII punctuation or symbols, such as `。` or `,`, so CJK prose that follows it stays outside the link. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Copying a selection that lies inside one `MarkdownText` document and includes rendered math puts each whole formula on the clipboard as TeX source in both plain text and HTML, delimited as `$…$` inline and `$$…$$` for display math. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A bare URL ends before non-ASCII punctuation or symbols, such as `。` or `,`, so CJK prose that follows it stays outside the link. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. @@ -230,6 +230,7 @@ These limits define how the atoms behave at the edges; they are current package - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. +- **Math copies as TeX only within one Markdown document** — a selection that reaches outside its `MarkdownText` root, such as from a user message into a reply, keeps the browser's native copy, which joins KaTeX's hidden MathML text with the visible glyphs. Inside one document, the plain text of non-math content comes from `innerText`, so code-block language labels and copy-button text are included, as in native copy. - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. - **Bare URLs end before non-ASCII punctuation** — the [tokenizer patch](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch) and the [transform patch](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch) end a GFM autolink literal at non-ASCII punctuation, symbols, or whitespace, so a URL with an unencoded character such as `(` or `・` in its path links only up to it; write such a URL as `<…>` or `[…](…)`. A URL followed directly by CJK letters still links them, because paths such as `/wiki/中文` are valid. Both patches are keyed to exact versions: an upgrade of either package must re-apply or retire its patch, otherwise the boundary rule silently disappears. - **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d7c8b68585..bd098df27e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -45,7 +45,7 @@ kind: "package-library" | `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。描边操作使用内侧细线。ref 指向原生按钮,供焦点控制与浮层锚定使用。 | | `Switch` | 36×20 的双态开关。关闭态滑块读取 `--dsw-alias-switch-thumb`,在两种主题下均保持亮色;开启态滑块与品牌色轨道形成对比。禁用控件使用半透明样式。`label` 必填,控件不可能在没有名称的情况下发布。 | | `SegmentedControl` | 两段或更多等宽分段加一个滑动指示块的 tablist,用于在几种模式间切换一张卡片或面板;选中项由调用方持有,`label` 为列表命名。`id` 派生每个 tab 的 id(`-`)及其控制的面板 id(`--panel`),面板由调用方渲染并用 `aria-labelledby` 指回 tab;分段可 `disabled` 并带 `title`,控件级 `disabled` 在当前面板有进行中的操作时锁住全部分段。 | -| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | +| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。`modalAutofocus` 将输入标记为所属 Modal 的初始焦点,本身不会移动焦点。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | | `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | @@ -58,11 +58,11 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,调用方可提供功能专属覆盖。语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。`headerAccessory` 将独立操作放在折叠触发区域和流光之外;有此操作时,标题行收窄到内容宽度。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | -| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | +| `AnimatedRows` | 带键列表的位移动画:移动为 200 ms,进出淡变为 100 ms。列表内发生指针或键盘输入后才启用,遵循减少动态效果设置,加载或替换视图时直接完成。只有同时与列表和浏览器窗口相交的行才做动画,所有行仍保持挂载。为行标记 `data-row-key` 并传入对应的 `rowKeys`,为父容器设置定位以承载不可交互的退出层;`role` 和 `label` 由调用方提供。 | | `useRowDragPreview` | 使用不可交互的离屏行副本生成原生拖拽预览。可选的 `inheritedProperties` 列表指定离屏包装层需要复制其计算值的 CSS 自定义属性;省略时不复制。列表引用需保持稳定,因为替换列表会清除活动预览。未列出的自定义属性按预览所在位置的正常继承规则生效。传入设置表面底色的类名;拖拽来自子级手柄时,将行元素传给 `start`;`clear` 和卸载会移除副本。 | | `TextShimmer` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,扫动一秒,再静止 500ms。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `TextShimmer` 外,只有文字和分隔符参与扫光。字符串子节点沿用稳定的文字接口;嵌套的 `TextShimmer` 共用外层动画。活动期间组合子节点渲染两次,因此不得带副作用或元素 id。分隔符标记 `data-shimmer-decoration`,让填色参与扫光。`DisclosureRow` 将 `contentClassName` 传给文字区域,将 `contentLayoutClassName` 传给内部布局。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 | -| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。 | -| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | +| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 色层与弹窗淡入,背景模糊始终完整生效,并遵循减少动态效果偏好。调用方已模糊源页面时设置 `backdropBlur={false}`。`tone="warning"` 在标题中显示橙色警告图标,并移除正文额外的顶部间距;其他弹窗的间距保持不变。 | +| `RiskConfirmation` | 以显式复选框把关的敏感操作确认,采用共用的警告标题布局。 | | `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | | `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 | @@ -115,7 +115,7 @@ kind: "package-library" 明确的 `./`、`../`、斜杠或反斜杠开头的路径(包括 UNC),以及 `C:/work/file.ts` 这样的 Windows 盘符路径优先于 URL 解析。其余目标若能被绝对 URL 解析器接受,就保留为 URL:`index.ts:24`、`Makefile:12` 和 `localhost:3000` 均为链接,不补协议,也不预先检查能否打开。文件引用使用 `./index.ts:24`。其他目标视为路径。正文链接禁止 `javascript:`、`vbscript:`、`data:` 和 `blob:`;其余可解析的协议保留原生导航。HTTP(S) 链接保留委托导航和修饰键点击行为。不添加点击后的失败提示。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。裸 URL 在非 ASCII 标点或符号(如 `。`、`,`)前结束,紧随其后的中文正文不会并入链接。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。复制位于同一个 `MarkdownText` 文档内且包含已渲染公式的选区时,每个完整公式会以 TeX 源码同时写入剪贴板的纯文本和 HTML 格式,行内公式用 `$…$` 包裹,展示公式用 `$$…$$` 包裹。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。裸 URL 在非 ASCII 标点或符号(如 `。`、`,`)前结束,紧随其后的中文正文不会并入链接。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 @@ -230,6 +230,7 @@ kind: "package-library" - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 +- **公式仅在单个 Markdown 文档内复制为 TeX**:超出其 `MarkdownText` 根节点的选区(例如从用户消息延伸到回复)沿用浏览器原生复制,会将 KaTeX 隐藏的 MathML 文本与可见字形拼接在一起。在单个文档内,非公式内容的纯文本取自 `innerText`,因此与原生复制一样,会包含代码块语言标签和复制按钮文本。 - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 - **裸 URL 在非 ASCII 标点前结束**:[tokenizer 补丁](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch)和[转换补丁](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch)让 GFM 自动链接在非 ASCII 标点、符号或空白处结束,因此路径中含未编码 `(`、`・` 等字符的 URL 只链接到该字符之前;这类 URL 请写成 `<…>` 或 `[…](…)`。URL 后直接跟中日韩文字时仍会并入链接,因为 `/wiki/中文` 这类路径是合法的。两个补丁都绑定精确版本:升级任一依赖时必须重新应用或撤销对应补丁,否则该边界规则会悄然失效。 - **Go 结构体字段规则在本地打了补丁**:Go 语法加载时,重复匹配单行结构体字段的规则会替换为 [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) 合入的规则,因为内置规则在 `struct {` 之后含花括号注释等行上会指数级回溯。若依赖升级后该规则消失,加载语法会抛错,Go 代码以纯文本显示;`@shikijs/langs` 发布上游规则后删除此补丁。 diff --git a/packages/client/ui-primitives/src/AnimatedRows.tsx b/packages/client/ui-primitives/src/AnimatedRows.tsx index fb4a8fac68..ca676494c4 100644 --- a/packages/client/ui-primitives/src/AnimatedRows.tsx +++ b/packages/client/ui-primitives/src/AnimatedRows.tsx @@ -24,7 +24,10 @@ interface RowPosition { opacity: number } +type Viewport = Pick + interface RowSnapshot { + viewport: Viewport positions: Map removed: Map } @@ -34,13 +37,22 @@ function sameRows(previous: AnimatedRowsProps, next: AnimatedRowsProps): boolean && previous.rowKeys.every((key, index) => key === next.rowKeys[index]) } -function intersects(row: DOMRect, viewport: DOMRect): boolean { +function visibleViewport(list: HTMLElement): Viewport { + const rect = list.getBoundingClientRect() + return { + top: Math.max(0, rect.top), bottom: Math.min(window.innerHeight, rect.bottom), + left: Math.max(0, rect.left), right: Math.min(window.innerWidth, rect.right), + } +} + +function intersects(row: DOMRect, viewport: Viewport): boolean { return row.bottom > viewport.top && row.top < viewport.bottom && row.right > viewport.left && row.left < viewport.right } /** * Animates keyed rows only when their rendered membership or order changes. + * Rows must intersect both the list and the browser window before or after the commit. * Motion starts after the first pointer or keyboard input inside the mounted list. * The parent supplies a positioned container for the inert exit overlay. */ @@ -58,7 +70,7 @@ export class AnimatedRows extends Component { || typeof list.animate !== 'function' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return null - const viewport = list.getBoundingClientRect() + const viewport = visibleViewport(list) const positions = this.readPositions() const nextKeys = new Set(this.props.rowKeys) const removed = new Map() @@ -70,7 +82,7 @@ export class AnimatedRows extends Component { clone.style.paddingInlineStart = getComputedStyle(row.element).paddingInlineStart removed.set(key, { ...row, element: clone }) } - return { positions, removed } + return { viewport, positions, removed } } override componentDidUpdate(previous: AnimatedRowsProps, _state: unknown, snapshot: RowSnapshot | null): void { @@ -83,7 +95,7 @@ export class AnimatedRows extends Component { this.cancelMovements() const list = this.list.current as HTMLDivElement const overlay = this.overlay.current as HTMLDivElement - const viewport = list.getBoundingClientRect() + const viewport = visibleViewport(list) const origin = overlay.getBoundingClientRect() const positions = this.readPositions() @@ -91,7 +103,7 @@ export class AnimatedRows extends Component { this.removeExit(key) const previousRow = snapshot.positions.get(key) if (!intersects(row.rect, viewport) - && (previousRow === undefined || !intersects(previousRow.rect, viewport))) continue + && (previousRow === undefined || !intersects(previousRow.rect, snapshot.viewport))) continue if (previousRow === undefined) { this.move(row.element, [{ opacity: 0 }, { opacity: 1 }], ROW_FADE_MS) continue diff --git a/packages/client/ui-primitives/src/Checkbox.tsx b/packages/client/ui-primitives/src/Checkbox.tsx index bf1cbf9a65..e53456863d 100644 --- a/packages/client/ui-primitives/src/Checkbox.tsx +++ b/packages/client/ui-primitives/src/Checkbox.tsx @@ -11,19 +11,22 @@ import css from './Checkbox.module.css' * @param props.disabled - whether the control refuses changes. * @param props.title - optional localized hover text. * @param props.className - extra class for the label's placement. + * @param props.modalAutofocus - designate the input for the owning Modal's initial focus without React autoFocus. * @returns the label containing its checkbox. */ -export function Checkbox({ checked, onChange, label, disabled = false, title, className }: { +export function Checkbox({ checked, onChange, label, disabled = false, title, className, modalAutofocus = false }: { checked: boolean onChange: (next: boolean) => void label: string disabled?: boolean title?: string | undefined className?: string | undefined + modalAutofocus?: boolean }) { return ( diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 6be4e62b0a..d1a67e6a61 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -84,6 +84,22 @@ color: var(--dsw-alias-label-primary); } +.warning .title { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.warningIcon { + flex: none; + margin-top: 3px; + color: var(--dsw-alias-state-warn-primary); +} + +.warning .body { + margin-top: 0; +} + .close { flex: none; display: inline-flex; diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index e7fa7faa2e..3f47c7cbc8 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -2,7 +2,7 @@ import { useRef } from 'react' import type { KeyboardEventHandler, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCloseOutlineRegular } from './icons/index.tsx' +import { IconCloseOutlineRegular, IconWarningOutlineRegular } from './icons/index.tsx' import { useModalLayer } from './useModalLayer.ts' import css from './Modal.module.css' @@ -10,6 +10,7 @@ interface ModalBaseProps { open: boolean onClose: () => void title: string + tone?: 'warning' description?: string children?: ReactNode footer?: ReactNode @@ -31,6 +32,7 @@ type ModalProps = ModalBaseProps & ( * @param props.onClose - application close command, Escape, or mask click; while a menu is open inside the * dialog, Escape belongs to that menu first. * @param props.title - dialog heading (aria-label in every mode). + * @param props.tone - warning adds an amber title icon and compact title-to-body spacing; ignored for headless dialogs. * @param props.closeLabel - localized accessible close-button label. * @param props.description - optional supporting sentence under the title. * @param props.children - dialog body; mark its initial-focus control with @@ -47,7 +49,7 @@ type ModalProps = ModalBaseProps & ( */ export function Modal({ open, onClose, title, closeLabel, description, children, footer, className, contentClassName, - onKeyDownCapture, headless = false, backdropBlur = true, shortcutModal, + onKeyDownCapture, headless = false, backdropBlur = true, shortcutModal, tone, }: ModalProps) { const dialog = useRef(null) useModalLayer(dialog, open, onClose) @@ -61,7 +63,7 @@ export function Modal({ ref={dialog} tabIndex={-1} data-shortcut-modal={shortcutModal} - className={clsx(css.dialog, className)} + className={clsx(css.dialog, !headless && tone === 'warning' && css.warning, className)} role="dialog" aria-modal="true" aria-label={title} @@ -72,7 +74,10 @@ export function Modal({ <>
      -

      {title}

      +

      + {tone === 'warning' &&

      diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index 3c29131429..6a7db49ffa 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -16,52 +16,26 @@ } } -.warning { - display: flex; - align-items: flex-start; - gap: 10px; +.description { + margin: 0; color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 22px; } -.warning p { - margin: 0; -} - -.warningIcon { - flex: none; - margin-top: 2px; - color: var(--dsw-alias-state-error-primary); -} - -.acknowledgement { - display: flex; +.confirmation .acknowledgement { align-items: flex-start; - gap: 10px; - margin-top: 20px; - color: var(--dsw-alias-label-primary); + margin-top: 16px; + color: var(--dsw-alias-label-secondary); font-size: 14px; - line-height: 22px; - cursor: pointer; + font-weight: 400; + line-height: 20px; } -.acknowledgement input { - flex: none; - width: 16px; - height: 16px; - margin: 3px 0 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; -} - -.acknowledgement input:focus-visible { - outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); - outline-offset: 2px; -} - -.acknowledgement input:disabled { - cursor: default; +.confirmation .acknowledgement input { + width: 14px; + height: 14px; + margin-top: 3px; } .modalAction { diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index 7109d096b2..1ad24e8298 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -3,7 +3,7 @@ * must gate a sensitive action behind an explicit checkbox. */ import { Button } from './Button.tsx' -import { IconWarningOutlineRegular } from './icons/index.tsx' +import { Checkbox } from './Checkbox.tsx' import { Modal } from './Modal.tsx' import css from './RiskConfirmation.module.css' @@ -45,6 +45,7 @@ export function RiskConfirmation({ open={open} onClose={onCancel} title={title} + tone="warning" closeLabel={closeLabel} className={css.confirmation ?? ''} contentClassName={css.confirmationContent ?? ''} @@ -64,20 +65,15 @@ export function RiskConfirmation({ )} > -
      - -

      {description}

      -
      - +

      {description}

      + ) } diff --git a/packages/client/ui-primitives/src/icons/artwork.ts b/packages/client/ui-primitives/src/icons/artwork.ts index 073f9988d3..28a3819995 100644 --- a/packages/client/ui-primitives/src/icons/artwork.ts +++ b/packages/client/ui-primitives/src/icons/artwork.ts @@ -20,6 +20,7 @@ interface IconArtwork { /** Default artwork keyed by glyph name; symbol ids derive from these keys. */ export const ICON_ARTWORK = { + 'layers-outline': { size: 16, svg: strokeWidth => `` }, 'save-outline': { size: 16, svg: strokeWidth => `` }, 'new-chat-outline': { size: 16, svg: strokeWidth => `` }, 'search-outline': { size: 16, svg: strokeWidth => `` }, @@ -59,7 +60,8 @@ export const ICON_ARTWORK = { 'check-circle-fill': { size: 16, svg: strokeWidth => `` }, 'warning-triangle-outline': { size: 16, svg: strokeWidth => `` }, 'user-outline': { size: 16, svg: strokeWidth => `` }, - 'users-outline': { size: 16, svg: strokeWidth => `` }, + // Users has inset contours in the 16px box; only coordinates, never stroke widths, carry the inset. + 'users-outline': { size: 16, svg: strokeWidth => `` }, 'paper-plane-outline': { size: 14, svg: strokeWidth => `` }, 'stop-fill': { size: 16, svg: strokeWidth => `` }, 'paperclip-outline': { size: 16, svg: strokeWidth => `` }, diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index a8f86d71ab..b6f03f1299 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -24,6 +24,12 @@ function productIcon(glyph: IconGlyph, weight: IconWeight): (props: IconProps) = ) } +/** Regular one-pixel IconLayersOutline artwork. */ +export const IconLayersOutlineRegular = productIcon('layers-outline', 'regular') + +/** Medium IconLayersOutline artwork with a 1.3px stroke. */ +export const IconLayersOutlineMedium = productIcon('layers-outline', 'medium') + /** Regular one-pixel IconNewChatOutline artwork. */ export const IconNewChatOutlineRegular = productIcon('new-chat-outline', 'regular') diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 004c962963..8f1c4c7d29 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -14,6 +14,7 @@ import { memo, useMemo, useRef } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' +import { copyKatexAsTex } from './copy-tex.ts' import { IncrementalMarkdownParser } from './incremental.ts' import { parseGfm, parseGfmWithMath } from './parse.ts' import { @@ -179,6 +180,8 @@ class StreamingRenderer { * including `#L24` / `#L24-L30` fragments and `:24` / `:24-30` / `:24:5` suffixes * (ranges open at their first line, columns are ignored, and invalid locations * open only the file; see the README for URL/path classification). + * Copying a selection inside the document writes selected formulas as + * `$…$` / `$$…$$` TeX source. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML never * renders as markup, and unsafe protocols are disabled. Local links without an opener remain text; * absolute HTTP(S) images render directly. @@ -210,5 +213,6 @@ export const MarkdownText = memo(function MarkdownText({ }, [text, streaming, labels, fileMentions, pathImages, omitHtml]) return
      {children}
      + data-markdown-variant={variant === 'compact' ? variant : undefined} + onCopy={(event) => { copyKatexAsTex(event.nativeEvent, event.currentTarget) }}>{children}
      }) diff --git a/packages/client/ui-primitives/src/markdown/copy-tex.ts b/packages/client/ui-primitives/src/markdown/copy-tex.ts new file mode 100644 index 0000000000..0b600281ec --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/copy-tex.ts @@ -0,0 +1,53 @@ +/** Clipboard projection that copies rendered KaTeX formulas as their TeX source. */ + +/** The whole formula around a node, including the display wrapper that marks display math. */ +function closestFormula(node: Node): Element | null { + const element = node instanceof Element ? node : node.parentElement + return element?.closest('.katex-display') ?? element?.closest('.katex') ?? null +} + +/** + * Rewrite a copy whose selection lies inside one rendered Markdown document and + * includes rendered math, so each formula becomes its trimmed TeX source, + * delimited as `$…$` inline and `$$…$$` for display math, in both the + * plain-text and HTML clipboard data. A selection endpoint inside a formula + * extends to the whole formula in the clipboard only; the visible selection is + * unchanged. A copy without math, a selection reaching outside `root`, and a + * copy whose default an inner listener already prevented keep their data. + * @param event - Native copy event. + * @param root - The rendered Markdown document root. + */ +export function copyKatexAsTex(event: ClipboardEvent, root: HTMLElement): void { + const selection = root.ownerDocument.getSelection() + if (event.defaultPrevented || event.clipboardData === null || selection === null + || selection.rangeCount !== 1 || selection.isCollapsed) return + const range = selection.getRangeAt(0).cloneRange() + if (!root.contains(range.commonAncestorContainer)) return + const startFormula = closestFormula(range.startContainer) + if (startFormula !== null) range.setStartBefore(startFormula) + const endFormula = closestFormula(range.endContainer) + if (endFormula !== null) range.setEndAfter(endFormula) + const fragment = range.cloneContents() + const formulas = [...fragment.querySelectorAll('.katex')] + if (formulas.length === 0) return + for (const formula of formulas) { + const tex = formula.querySelector('annotation[encoding="application/x-tex"]')?.textContent.trim() + if (tex === undefined) continue + const delimiter = formula.closest('.katex-display') === null ? '$' : '$$' + formula.replaceWith(`${delimiter}${tex}${delimiter}`) + } + // innerText applies the browser's own block and line-break serialization, + // which needs a rendered element; an off-screen child of the root keeps the + // Markdown styles that decide block layout while staying invisible. + const container = root.ownerDocument.createElement('div') + container.style.cssText = 'position:fixed;top:0;left:-100000px;pointer-events:none' + container.append(fragment) + root.append(container) + try { + event.clipboardData.setData('text/plain', container.innerText) + event.clipboardData.setData('text/html', container.innerHTML) + } finally { + container.remove() + } + event.preventDefault() +} diff --git a/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx b/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx index fe46cb5a2b..57b331ffc8 100644 --- a/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx +++ b/packages/client/ui-primitives/tests/animated-rows.client.spec.tsx @@ -73,6 +73,15 @@ function finish(animation: Animation): void { animation.onfinish?.call(animation, new Event('finish') as AnimationPlaybackEvent) } +function naturalHeightRows(tree: HTMLElement, top = 0): void { + measure.mockImplementation(function (this: HTMLElement) { + const items = Array.from(tree.querySelectorAll('[data-row-key]')) + if (this === tree) return new DOMRect(0, top, 200, items.length * 34) + if (this.dataset.rowKey !== undefined) return new DOMRect(0, top + items.indexOf(this) * 34, 200, 32) + return new DOMRect(0, top, 200, items.length * 34) + }) +} + describe('AnimatedRows', () => { it('settles initial arrivals and glides a subsequent pin reorder', () => { const view = render(rows(['a'])) @@ -168,6 +177,57 @@ describe('AnimatedRows', () => { for (const call of animations) expect(call.cancel).toHaveBeenCalled() }) + it('animates only rows inside the window when 300 natural-height rows reorder', () => { + vi.stubGlobal('innerHeight', 324) + const keys = Array.from({ length: 300 }, (_, index) => String(index)) + const view = render(rows(keys)) + const tree = arm() + naturalHeightRows(tree, 50 - 100 * 34) + const reordered = keys.map((_, index) => String(index % 2 === 0 ? index + 1 : index - 1)) + view.rerender(rows(reordered)) + expect(animations.map(call => call.element.dataset.rowKey)) + .toEqual(['99', '98', '101', '100', '103', '102', '105', '104', '107', '106', '109', '108']) + expect(animations.every(call => call.options.duration === 200)).toBe(true) + expect(tree.querySelectorAll('[data-row-key]')).toHaveLength(300) + }) + + it('fades entries and exits only inside the window', () => { + vi.stubGlobal('innerHeight', 68) + const view = render(rows(['a', 'b', 'c', 'd', 'e'])) + const tree = arm() + naturalHeightRows(tree) + view.rerender(rows(['x', 'y', 'z', 'w', 'v'])) + expect(animations.map(call => [call.element.textContent, call.options.duration])).toEqual([ + ['x', 100], ['y', 100], ['a', 100], ['b', 100], + ]) + expect(animations.filter(call => call.element.inert)).toHaveLength(2) + }) + + it('compares previous positions with the window bounds measured before the commit', () => { + vi.stubGlobal('innerHeight', 102) + const content = (keys: readonly string[], top: number) =>
      {rows(keys)}
      + const view = render(content(['a', 'b', 'c', 'd', 'e', 'f'], 0)) + const tree = arm() + const shift = screen.getByTestId('shift') + measure.mockImplementation(function (this: HTMLElement) { + const items = Array.from(tree.querySelectorAll('[data-row-key]')) + const top = Number.parseFloat(shift.style.top) + if (this.dataset.rowKey !== undefined) return new DOMRect(0, top + items.indexOf(this) * 34, 200, 32) + return new DOMRect(0, top, 200, items.length * 34) + }) + view.rerender(content(['b', 'a', 'c', 'd', 'f', 'e'], -136)) + expect(animations.map(call => call.element.dataset.rowKey)).toEqual(['b', 'a', 'c', 'f', 'e']) + }) + + it('limits natural-height lists to the window', () => { + vi.stubGlobal('innerHeight', 68) + const view = render(rows(['a', 'b', 'c', 'd'])) + const tree = arm() + naturalHeightRows(tree) + view.rerender(rows(['b', 'a', 'd', 'c'])) + expect(animations.map(call => call.element.dataset.rowKey)).toEqual(['b', 'a']) + }) + it('does not animate rows that stay outside the list viewport', () => { const view = render(rows(['a', 'b', 'c', 'd', 'e'])) arm() diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 3e7033474b..1bd9692b64 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -898,6 +898,22 @@ describe('Modal', () => { expect(onClose).toHaveBeenCalledTimes(2) }) + it('places a decorative warning icon in the title only for warning dialogs', () => { + const view = render( {}}> +

      Review the consequences.

      +
      ) + const heading = screen.getByRole('heading', { name: 'Confirm change' }) + const icon = heading.querySelector('svg') + expect(icon).not.toBeNull() + expect(icon?.getAttribute('aria-hidden')).toBe('true') + expect(screen.getByText('Review the consequences.').querySelector('svg')).toBeNull() + view.rerender( {}}>Body) + expect(screen.getByRole('heading', { name: 'Confirm change' }).querySelector('svg')).toBeNull() + view.rerender( {}}>Body) + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.getByRole('dialog').querySelector('svg')).toBeNull() + }) + it('renders headless content without the default close chrome', () => { render( {}} title="Custom surface" headless backdropBlur={false}> diff --git a/packages/client/ui-primitives/tests/checkbox.client.spec.tsx b/packages/client/ui-primitives/tests/checkbox.client.spec.tsx index 838a589dab..1386decedc 100644 --- a/packages/client/ui-primitives/tests/checkbox.client.spec.tsx +++ b/packages/client/ui-primitives/tests/checkbox.client.spec.tsx @@ -25,6 +25,14 @@ it('refuses label activation while disabled', () => { expect(screen.getByRole('checkbox').checked).toBe(true) }) +it('marks initial modal focus only when requested', () => { + const view = render( {}} />) + expect(screen.getByRole('checkbox').hasAttribute('data-modal-autofocus')).toBe(false) + view.rerender( {}} modalAutofocus />) + expect(screen.getByRole('checkbox').hasAttribute('data-modal-autofocus')).toBe(true) + expect(document.activeElement).not.toBe(screen.getByRole('checkbox')) +}) + it('preserves caller hover text', () => { const onChange = vi.fn() render() diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 00ba3a3bf1..9bf949a9db 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -27,8 +27,13 @@ function artwork(glyph: IconGlyph, weight: IconWeight): Document { } describe('product icon set', () => { - it('exports regular and medium variants for all 97 public glyphs', () => { - expect(iconNames.length).toBe(194) + it('exports regular and medium variants for every public glyph', () => { + const publicGlyphs = glyphs.filter(glyph => glyph !== 'apps-add-compact-outline') + const expectedNames = publicGlyphs.flatMap((glyph) => { + const name = glyph.split('-').map(part => part[0]!.toUpperCase() + part.slice(1)).join('') + return [`Icon${name}Regular`, `Icon${name}Medium`] + }) + expect([...iconNames].sort()).toEqual(expectedNames.sort()) expect(iconNames.some(name => /\d+$/.test(name))).toBe(false) const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length)) const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length)) @@ -53,6 +58,75 @@ describe('product icon set', () => { expect(render().container.querySelector('svg')!.getAttribute('width')).toBe('16') }) + it.each([ + ['regular', primitives.IconUsersOutlineRegular, 1], + ['medium', primitives.IconUsersOutlineMedium, 1.3], + ] as const)('keeps the inset Users contours and %s stroke at every caller size', (weight, Icon, stroke) => { + const svg = artwork('users-outline', weight).documentElement + const paths = [...svg.querySelectorAll('path')] + const contours = paths.map(path => path.getAttribute('d')!) + // A 0.91 coordinate scale about (8, 8) preserves the four cubic contours without scaling their strokes. + expect(contours).toEqual([ + 'M6.18 8.2275C7.5620898 8.2275 8.6825 7.1070898 8.6825 5.725C8.6825 4.3429102 7.5620898 3.2225 6.18 3.2225C4.7979102 3.2225 3.6775 4.3429102 3.6775 5.725C3.6775 7.1070898 4.7979102 8.2275 6.18 8.2275Z', + 'M1.63 13.915C1.63 11.185 3.905 10.0475 6.18 10.0475C8.455 10.0475 10.73 11.185 10.73 13.915', + 'M10.275 3.359C11.3215 3.7685 12.0495 4.6785 12.0495 5.725C12.0495 6.7715 11.3215 7.6815 10.275 8.091', + 'M12.004 10.366C13.369 11.003 14.37 12.186 14.37 13.915', + ]) + // These contour extrema are endpoints; every Bezier control point stays inside the same bounds. + const coordinates = contours.flatMap(path => path.match(/\d+(?:\.\d+)?/g)!.map(Number)) + const xs = coordinates.filter((_, index) => index % 2 === 0) + const ys = coordinates.filter((_, index) => index % 2 === 1) + expect([Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]) + .toEqual([1.63, 3.2225, 14.37, 13.915]) + expect(svg.getAttribute('viewBox')).toBe('0 0 16 16') + expect(svg.getAttribute('fill')).toBe('none') + expect(svg.getAttribute('stroke-width')).toBe(String(stroke)) + expect(svg.querySelector('[transform], [vector-effect], [stroke-width]')).toBeNull() + expect(svg.hasAttribute('transform')).toBe(false) + expect(svg.hasAttribute('vector-effect')).toBe(false) + expect(paths.every(path => path.getAttribute('stroke') === 'currentColor')).toBe(true) + expect(primitives.ICON_SYMBOLS[`dsw-icon-users-outline-${weight}`]) + .toBe(ICON_ARTWORK['users-outline'].svg(stroke)) + const view = render() + const rendered = view.container.querySelector('svg')! + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['16', '16']) + view.rerender() + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['14', '14']) + expect(rendered.querySelector('use')?.getAttribute('href')).toBe(`#dsw-icon-users-outline-${weight}`) + }) + + it.each([ + ['regular', primitives.IconLayersOutlineRegular, 1], + ['medium', primitives.IconLayersOutlineMedium, 1.3], + ] as const)('draws the inset three rounded layers with the %s stroke', (weight, Icon, stroke) => { + const svg = artwork('layers-outline', weight).documentElement + const paths = [...svg.querySelectorAll('path')] + // A 0.87 scale about (8, 8) insets coordinates and arc radii, retaining rotations, flags, and strokes. + expect(paths.map(path => path.getAttribute('d'))).toEqual([ + 'M8.481371 2.304371 a1.159971 1.159971 0 0 0-.962742 0 L2.547971 4.566371 a.580029 .580029 0 0 0 0 1.0614 l4.9764 2.267829 a1.159971 1.159971 0 0 0 .962829 0 l4.9764-2.262 a.580029 .580029 0 0 0 0-1.0614Z', + 'M2.199971 8 a.580029 .580029 0 0 0 .336429 .527829 l4.987971 2.267742 a1.159971 1.159971 0 0 0 .957 0 l4.9764-2.262 A.580029 .580029 0 0 0 13.800029 8', + 'M2.199971 10.899971 a.580029 .580029 0 0 0 .336429 .527829 l4.987971 2.267829 a1.159971 1.159971 0 0 0 .957 0 l4.9764-2.262 A.580029 .580029 0 0 0 13.800029 10.899971', + ]) + expect(svg.children).toHaveLength(3) + expect(svg.getAttribute('viewBox')).toBe('0 0 16 16') + expect(svg.getAttribute('fill')).toBe('none') + expect(svg.getAttribute('stroke-width')).toBe(String(stroke)) + expect(svg.getAttribute('stroke-linecap')).toBe('round') + expect(svg.getAttribute('stroke-linejoin')).toBe('round') + expect(svg.querySelector('[transform], [vector-effect], [stroke-width], [stroke-linecap], [stroke-linejoin]')).toBeNull() + expect(svg.hasAttribute('transform')).toBe(false) + expect(svg.hasAttribute('vector-effect')).toBe(false) + expect(paths.every(path => path.getAttribute('stroke') === 'currentColor')).toBe(true) + expect(primitives.ICON_SYMBOLS[`dsw-icon-layers-outline-${weight}`]) + .toBe(ICON_ARTWORK['layers-outline'].svg(stroke)) + const view = render() + const rendered = view.container.querySelector('svg')! + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['16', '16']) + view.rerender() + expect([rendered.getAttribute('width'), rendered.getAttribute('height')]).toEqual(['14', '14']) + expect(rendered.querySelector('use')?.getAttribute('href')).toBe(`#dsw-icon-layers-outline-${weight}`) + }) + it('exports the shield contour and regular stroke for composite glyphs', () => { const shield = artwork('shield-outline', 'regular') expect(shield.querySelector('path')?.getAttribute('d')).toBe(primitives.SHIELD_OUTLINE_PATH) diff --git a/packages/client/ui-primitives/tests/markdown-copy-tex.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-copy-tex.client.spec.tsx new file mode 100644 index 0000000000..abe9353102 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-copy-tex.client.spec.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { MarkdownText } from './markdown-test-components.tsx' +import { copyKatexAsTex } from '../src/markdown/copy-tex.ts' + +// jsdom implements no layout or innerText, so textContent stands in for it here +// and these specs assert only the TeX fragments; math-rendering.e2e.ts covers +// the browser's line breaks. +const nativeInnerText = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText') +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'innerText', { + configurable: true, + get(this: HTMLElement) { return this.textContent }, + }) +}) +afterAll(() => { + if (nativeInnerText === undefined) Reflect.deleteProperty(HTMLElement.prototype, 'innerText') + else Object.defineProperty(HTMLElement.prototype, 'innerText', nativeInnerText) +}) +afterEach(() => { + document.getSelection()?.removeAllRanges() + cleanup() +}) + +function select(start: Node, startOffset: number, end: Node, endOffset: number): void { + const range = document.createRange() + range.setStart(start, startOffset) + range.setEnd(end, endOffset) + document.getSelection()!.removeAllRanges() + document.getSelection()!.addRange(range) +} + +function copy(target: Element): { data: Map; prevented: boolean } { + const data = new Map() + const clipboardData = { setData: (type: string, value: string) => { data.set(type, value) } } + const prevented = !fireEvent.copy(target, { clipboardData }) + return { data, prevented } +} + +describe('MarkdownText math copy', () => { + it('copies selected inline and display formulas as delimited TeX source', () => { + const view = render() + const paragraphs = view.container.querySelectorAll('p') + const inlineVisual = view.container.querySelector('.katex-html')! + const lastText = paragraphs[paragraphs.length - 1]!.firstChild! + select(inlineVisual.firstChild!, 0, lastText, 3) + const { data, prevented } = copy(inlineVisual) + expect(prevented).toBe(true) + for (const type of ['text/plain', 'text/html']) { + expect(data.get(type)).toContain('$E=mc^2$ and') + expect(data.get(type)).toContain('$$\\frac{a}{b}$$') + expect(data.get(type)).toContain('end') + } + expect(data.get('text/html')).not.toMatch(/katex-(?:html|mathml)/u) + expect(view.container.textContent).not.toContain('$E=mc^2$') + expect(document.getSelection()!.getRangeAt(0).startContainer).toBe(inlineVisual.firstChild) + }) + + it('extends a selection starting or ending inside a formula to the whole formula', () => { + const view = render() + const paragraph = view.container.querySelector('p')! + const visualText = view.container.querySelector('.katex-html .mord')!.firstChild! + select(paragraph.firstChild!, 0, visualText, 1) + expect(copy(paragraph).data.get('text/plain')).toBe('Energy $E=mc^2$') + select(visualText, 0, paragraph.lastChild!, 3) + expect(copy(paragraph).data.get('text/plain')).toBe('$E=mc^2$ ta') + }) + + it('marks a selection entirely inside display math as display TeX', () => { + const view = render() + const glyph = view.container.querySelector('.katex-display .katex-html .mord')! + select(glyph, 0, glyph, 1) + expect(copy(glyph).data.get('text/plain')).toBe('$$\\frac{a}{b}$$') + }) + + it('trims the trailing newline of a fenced math block', () => { + const view = render() + const glyph = view.container.querySelector('.katex-display .katex-html .mord')! + select(glyph, 0, glyph, 1) + expect(copy(glyph).data.get('text/plain')).toBe('$$\\frac{a}{b}$$') + }) + + it('keeps native copy without math or with a collapsed selection', () => { + const view = render() + const text = view.container.querySelector('p')!.firstChild! + select(text, 0, text, 5) + expect(copy(text.parentElement!)).toEqual({ data: new Map(), prevented: false }) + select(text, 0, text, 0) + expect(copy(text.parentElement!).prevented).toBe(false) + }) + + it('keeps native copy for a selection reaching outside the Markdown document', () => { + const view = render(<>

      outside

      ) + const formula = view.container.querySelector('.katex-html')! + const outside = view.container.lastElementChild!.firstChild! + select(formula, 0, outside, 3) + expect(copy(formula)).toEqual({ data: new Map(), prevented: false }) + }) + + it('keeps data from an inner listener that prevented the default', () => { + const view = render() + const paragraph = view.container.querySelector('p')! + paragraph.addEventListener('copy', (event) => { + event.clipboardData!.setData('text/plain', 'inner') + event.preventDefault() + }) + select(paragraph, 0, paragraph, paragraph.childNodes.length) + expect(copy(paragraph).data).toEqual(new Map([['text/plain', 'inner']])) + }) + + it('ignores a copy without clipboard data', () => { + const view = render() + const root = view.container.firstElementChild as HTMLElement + select(root, 0, root, root.childNodes.length) + const event = new Event('copy', { cancelable: true }) as ClipboardEvent + Object.defineProperty(event, 'clipboardData', { value: null }) + copyKatexAsTex(event, root) + expect(event.defaultPrevented).toBe(false) + }) + + it('leaves a formula without TeX annotation as rendered text', () => { + const root = document.createElement('div') + root.innerHTML = '

      a visual b

      ' + document.body.append(root) + root.addEventListener('copy', (event) => { copyKatexAsTex(event, root) }) + select(root, 0, root, 1) + expect(copy(root).data.get('text/plain')).toBe('a visual b') + root.remove() + }) +}) diff --git a/packages/client/ui-renderer/README.i18n.yaml b/packages/client/ui-renderer/README.i18n.yaml index 5022127e14..df00ec41b1 100644 --- a/packages/client/ui-renderer/README.i18n.yaml +++ b/packages/client/ui-renderer/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-renderer/use-this-package: - en: 39d5f36f7cb7d680 - zh: a61f0aa8b339e06f + en: b83a441098d5b7fd + zh: 57cd24edbc2e6602 /deepseek-ai-dsh-client-ui-renderer/use-this-package/what-mounting-does: en: 1d5350799ce072d0 zh: 942e543fdab51818 @@ -45,8 +45,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-renderer/known-limitations-and-deferred-work: - en: 5ebaf02736035a44 - zh: 3c04f28da7ec6c40 + en: 14bccc68da30d0f3 + zh: 6b6b306556cdd8a4 /deepseek-ai-dsh-client-ui-renderer/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-renderer/README.md b/packages/client/ui-renderer/README.md index e6699e8d98..13a759a182 100644 --- a/packages/client/ui-renderer/README.md +++ b/packages/client/ui-renderer/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -This package is infrastructure: the web shell and the boot kernel are its only direct consumers. A composition needs it whenever it wants a React-rendered GUI — `dsh-client-web` loads the roster, waits for every entry to activate, then calls `ctx.uiRenderer.mount(container)`. +This package is infrastructure: the web shell and the boot kernel are its only direct consumers. A composition needs it whenever it wants a React-rendered GUI — `dsh-client-web` loads the roster, waits for it to settle and for the boot audit to accept it, then calls `ctx.uiRenderer.mount(container)`. ### What mounting does @@ -91,7 +91,7 @@ None; this package neither assembles nor sends a provider request. These limits define when the application frame appears and how far per-region readiness goes; they are current package constraints. -- **The first application frame waits for every client entry** — the boot kernel hands over the mount point only after the loader roster settles; per-region readiness remains deferred. +- **The first application frame waits for the whole client roster** — the boot kernel hands over the mount point only after the loader roster settles and the boot audit accepts it; per-region readiness remains deferred. - **Slot rendering has no Suspense integration or per-entry lazy loading** — the complete plugin roster settles before the renderer mounts the root. diff --git a/packages/client/ui-renderer/README.zh.md b/packages/client/ui-renderer/README.zh.md index bac1dc9064..cdf8de1820 100644 --- a/packages/client/ui-renderer/README.zh.md +++ b/packages/client/ui-renderer/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -本包属于基础设施:Web 外壳与启动内核是它仅有的直接消费方。只要组合需要 React 渲染的 GUI,就需要它——`dsh-client-web` 加载名册,等待每个 entry 激活,然后调用 `ctx.uiRenderer.mount(container)`。 +本包属于基础设施:Web 外壳与启动内核是它仅有的直接消费方。只要组合需要 React 渲染的 GUI,就需要它——`dsh-client-web` 加载名册,等待名册稳定且启动审计通过,然后调用 `ctx.uiRenderer.mount(container)`。 ### 挂载做什么 @@ -91,7 +91,7 @@ React、React DOM、Cordis、ui-slots 与 ui-primitives 通过 Web 外壳的静 这些限制说明应用首帧何时出现、按区域就绪能走多远;它们是当前包约束。 -- **应用首帧会等待全部客户端 entry**:启动内核只在 loader 名册稳定后交出挂载点;按区域就绪仍属暂缓事项。 +- **应用首帧会等待整个客户端名册**:启动内核只在 loader 名册稳定且启动审计通过后交出挂载点;按区域就绪仍属暂缓事项。 - **slot 渲染没有 Suspense 集成或逐 entry 惰性加载**:完整插件名册稳定后,渲染器才挂载根节点。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 02b3bd6729..3c28ce7528 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -9,8 +9,8 @@ en: 0db6fa836e1ff511 zh: 0e2968763e1fb7a5 /deepseek-ai-dsh-client-ui-settings-models/summary: - en: 51252c1e9fa6da0a - zh: 227b54d7898417fa + en: 2d39061747367414 + zh: 35edfca13c457deb /deepseek-ai-dsh-client-ui-settings-models/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,11 +18,11 @@ en: db5aaf7f702e1f0c zh: 409a78c14fb2b6e3 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/api-keys: - en: f4817eb2e3537ed9 - zh: e589c193fa85473f + en: d771da09da774692 + zh: 9566b3c0e95da8a6 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: ce52da7e6065ccc4 - zh: c63690cb5de5ea85 + en: 23c8e60086660111 + zh: f277b69a202f207c /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 8db24c5170..3e579642fe 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a ## Summary -`dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored write-only under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned preview notice and the conditional official-DeepSeek credential step. +`dsh-client-ui-settings-models` is the Models settings page of the dsh web client: users configure API keys (stored under the profile's credential reference), edit each provider's model list, and hand-declare custom pi-ai routes, with provider rows and one editor card at a time. The page joins the provider directory, the settings document, and the credential descriptions into one shared snapshot, so a row's state stays consistent across all three. It also walks first-run users through two ordered dialogs — a versioned preview notice and the conditional official-DeepSeek credential step. ## Table of Contents @@ -41,15 +41,15 @@ Host configuration `credentialOnboarding` defaults to `true`. The Electron prelo ### API keys -API-key inputs start empty and use `autocomplete="new-password"` to ask browsers not to autofill saved login passwords. A nonempty draft has the same eye control as masked conversation text: **Show secret** reveals the current input and **Hide secret** masks it. Changing or clearing the input masks it again. Stored credentials are never read into the input. +The API key input shows a saved key masked and uses `autocomplete="new-password"` to avoid filling login passwords. Its eye button reveals the current value; editing or clearing the value masks it again. Environment values are never loaded into the input. -The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the credential variables of the route's `nativeAuth`, excluding `baseURLEnv` address variables, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. +The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `cordis.patch.yml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). The field's placeholder and help tooltip use the profile's `apiKeyEnv` when it has one; otherwise they use the credential variables of the route's `nativeAuth`, excluding `baseURLEnv` address variables, in lookup order, as the configurable-provider directory reports them. These display hints omit references containing `oauth` (case-insensitively), and describe saved credentials followed by the displayed environment fallbacks. This presentation filter does not change the [credential resolution order](../../../docs/subsystems/credentials.md#resolution), the adapter's complete environment list, or an explicit reference. A filtered explicit reference still has a usable key input with the plain placeholder and no tooltip. A native route that has no key authentication omits the key field and authentication help entirely; an explicit reference still enables the field. A profile without a reference on a route without `nativeAuth`, such as a hand-declared route, shows the plain key placeholder and no tooltip. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material. -The editor uses redacted `CredentialInfo` to choose the saved-credential placeholder or environment detection, without displaying a credential-source label. Refreshing the snapshot updates an open editor without a separate probe. The hint never receives a credential value or changes field writability. +Opening an existing provider editor reads its stored key through the owner-only `credentials.readStored` operation and shows it as a password input. The reveal button shows the loaded value. The key stays in that editor's local state; provider-directory and settings snapshots contain only redacted metadata. A failed read shows an error instead of an empty editor. Cancel discards edits, and Apply preserves an unchanged key, replaces an edited key, or removes a cleared key so environment authentication can apply. ### Editing a provider -A blank key field offers a saved credential when one is detected, with no status text below the input; otherwise it offers environment authentication. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A nonempty input shows no authentication status text. The page reads native variable names as well as named references, in batches of at most 64, and never reads their values into the Client. +An empty key field offers environment authentication; a saved key appears as a masked value. Redacted `credentials.describe` checks distinguish a detected environment reference, no detected variables, and an unconfirmed read. A missing-variable hint uses the warning color and names the first variable to configure. A detected environment reference uses green. Address configuration variables do not count as credentials. Hidden authentication references still participate in detection; a generic detected hint keeps their names hidden. Detection does not validate credentials, test a connection, or inspect provider-managed files such as AWS profiles and Google application-default credentials. A nonempty input shows no authentication status text. The page describes native variable names and named references in batches of at most 64. Editor reads return only provider-managed values, never environment values. An endpoint without a fixed catalog default shows the provider's `baseURLEnv` configuration names below the input. A profile endpoint remains the first placeholder choice. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 23763f168c..f96f6d9d2b 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(以只写方式存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化预览版说明,以及按条件显示的官方 DeepSeek 凭据步骤。 +`dsh-client-ui-settings-models` 是 dsh Web 客户端的 Models 设置页面:用户可以配置 API 密钥(存入 profile 的凭据引用之下)、编辑每个提供商的模型列表,并手工声明自定义 pi-ai 路由;页面以提供商行展示,一次只展开一张编辑卡片。该页面把提供商目录、设置文档与凭据描述合并为一个共享快照,因此行的状态在三个方面始终一致。它还会带首次运行的用户走两个有序弹窗——版本化预览版说明,以及按条件显示的官方 DeepSeek 凭据步骤。 ## 目录 @@ -41,15 +41,15 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会 ### API 密钥 -API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求浏览器不要自动填入已保存的登录密码。输入非空时,显示与会话密钥遮罩一致的眼睛按钮:**显示密钥**展示当前输入,**隐藏密钥**重新遮住输入。修改或清空输入后会重新遮住密钥。已保存凭据从不读入输入框。 +API 密钥输入框以掩码显示已保存的密钥,并通过 `autocomplete="new-password"` 避免填入登录密码。眼睛按钮显示当前值;修改或清空输入后重新遮住密钥。环境变量值从不加载到输入框。 -编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 凭据变量,并排除 `baseURLEnv` 地址变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 +编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `cordis.patch.yml` 从不携带密钥值。为新的 pi-ai 提供商留空密钥会保存一个不带引用的 profile,从而保留提供商原生认证(例如 Bedrock 凭据链或 Vertex ADC)。profile 有 `apiKeyEnv` 时,字段的占位文字与帮助提示采用该引用;否则按查找顺序采用可配置提供商目录为该路由报告的 `nativeAuth` 凭据变量,并排除 `baseURLEnv` 地址变量。这些显示提示会忽略包含 `oauth` 的引用(不区分大小写),并说明优先使用已保存凭据,其后为显示的环境后备。这项显示过滤不改变[凭据解析顺序](../../../docs/subsystems/credentials.zh.md#resolution)、适配器的完整环境变量列表或显式引用。被过滤的显式引用仍有可用的密钥输入框,使用普通占位文字且不显示帮助提示。没有密钥认证的原生路由不显示密钥字段及认证帮助;显式引用仍会启用该字段。路由没有 `nativeAuth`(例如手动声明的路由)且 profile 没有引用时,显示普通的密钥占位文字,且不显示帮助提示。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 -编辑器使用脱敏 `CredentialInfo` 选择已保存凭据的占位文字或环境检测,不显示凭据来源标签。快照刷新会更新打开的编辑器,无需单独探测。提示从不接收凭据值,也不改变字段的可写性。 +打开已有提供商的编辑器时,通过仅允许所有者调用的 `credentials.readStored` 操作读取已保存密钥,并以密码输入框显示。显示按钮可以展示已加载的值。密钥只保存在该编辑器的本地状态中;提供商目录与设置快照只包含脱敏元数据。读取失败时显示错误,不打开空白编辑器。「取消」丢弃编辑;「保存」保留未修改的密钥、替换已编辑的密钥,或删除已清空的密钥,从而允许使用环境认证。 ### 编辑提供商 -密钥字段留空时,已检测到已保存凭据则提示使用该凭据,输入框下方不显示状态文字;否则提示使用环境认证。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面读取原生变量名和具名引用,每批最多 64 项,不将变量值读入 Client。 +空密钥字段提示使用环境认证;已保存的密钥显示为掩码值。脱敏的 `credentials.describe` 检查区分已检测到环境引用、未检测到变量和读取状态未确认。缺少变量时使用黄色提示,并列出应配置的首个变量名。已检测到环境引用时使用绿色。地址配置变量不计作凭据。隐藏的认证引用仍参与检测;使用不含变量名的通用检测提示。检测不验证凭据、不测试连接,也不检查 AWS profile 或 Google 应用默认凭据等提供商管理的文件。输入非空时,不显示认证状态文字。页面按每批最多 64 项描述原生变量名和具名引用。编辑器读取只返回提供方管理的值,不返回环境变量值。 目录未提供固定默认端点时,输入框下方显示提供方的 `baseURLEnv` 配置变量名。Profile 中的端点仍是占位符的首选。 diff --git a/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx b/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx index e983a40303..3727000fc1 100644 --- a/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx +++ b/packages/client/ui-settings-models/src/client/ApiKeyInput.tsx @@ -1,4 +1,4 @@ -/** Write-only credential input with the same reveal behavior as masked conversation text. */ +/** Credential input with the same reveal behavior as masked conversation text. */ import { useState } from 'react' import type { InputHTMLAttributes, ReactNode } from 'react' import type { en } from './locales.ts' @@ -10,7 +10,7 @@ const EYE_OFF_PATH = `${EYE_CONTOUR}M2.5 2.5L13.5 13.5` /** Input attributes and section copy for the current credential draft. */ interface ApiKeyInputProps extends Omit, 'value' | 'type' | 'className' | 'autoComplete'> { - /** Unsaved input; stored credentials are never read into this field. */ + /** Stored credential or the user's current edit; initially masked. */ readonly value: string /** Section copy, including the same reveal labels as SecretText. */ readonly t: (key: keyof typeof en) => string diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 05c2b3e1ab..37a2ae989f 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -63,7 +63,7 @@ export interface ProbeTarget { baseURL?: string /** Wire protocol the form names, when it names one. */ api?: string - /** Key typed into the form and not yet stored, when there is one. */ + /** Loaded or newly entered key currently shown in the form, when there is one. */ apiKey?: string } diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 461ab37aad..adff556795 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -86,6 +86,8 @@ export interface ProviderIdentity { /** One existing row or dormant directory entry addressed by an editor action. */ interface EditorTarget extends ProviderIdentity { + /** Stored secret retained only while this editor is open. */ + initialCredential?: string settingsNs: string settingsPath: readonly string[] /** Writable credential identified under this page's conventional reference. */ @@ -123,16 +125,18 @@ interface ProviderEditorRenderProps extends Pick< /** Render an editor for either the setup posture or an expanded provider row. */ function renderProviderEditor({ target, row, ...props }: ProviderEditorRenderProps): ReactNode { + const credential = row.apiKeyEnv === undefined ? row.derivedCredential : row.credential return ( @@ -254,6 +258,10 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (state.status === 'ready' && !hasReadySnapshot) setHasReadySnapshot(true) if (state.status === 'error' && hasReadySnapshot) setHasReadySnapshot(false) const [editing, setEditing] = useState(undefined) + const [credentialRead, setCredentialRead] = useState< + { provider: string; status: 'loading' } | { provider: string; status: 'failed'; message: string } + >() + const editorLoading = credentialRead?.status === 'loading' const [addOpen, setAddOpen] = useState(false) const [addMode, setAddMode] = useState('catalog') /** The modes shown since the add card opened; each keeps its panel mounted. */ @@ -269,6 +277,26 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS const [savedTarget, setSavedTarget] = useState(undefined) const [dismissedSetup, setDismissedSetup] = useState>(() => new Set()) + const openEditor = async (row: ProviderRow, target: EditorTarget): Promise => { + setSavedTarget(undefined) + setAddOpen(false) + setEditing(undefined) + setCredentialRead(undefined) + const info = row.apiKeyEnv === undefined ? row.derivedCredential : row.credential + if (info?.configured === false) { + setEditing(target) + return + } + setCredentialRead({ provider: target.provider, status: 'loading' }) + const loaded = await operations.readCredential(row.apiKeyEnv ?? deriveKeyRef(target.provider)) + if (loaded.kind === 'refused') { + setCredentialRead({ provider: target.provider, status: 'failed', message: loaded.message }) + return + } + setCredentialRead(undefined) + setEditing({ ...target, ...loaded.value === undefined ? {} : { initialCredential: loaded.value } }) + } + const announceSaved = (target: ProviderIdentity): void => { // Announced only once the refreshed directory is in the snapshot the // notice reads its name from: an apply can rename the route, and the @@ -509,15 +537,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS type="button" className={styles['secondaryButton']} aria-label={providerCopy(t('editProvider'), target)} - disabled={!open && state.status !== 'ready'} + disabled={editorLoading || (!open && state.status !== 'ready')} + aria-busy={credentialRead?.provider === target.provider && editorLoading} onClick={() => { if (!open && controller.store.getSnapshot().status !== 'ready') return - setSavedTarget(undefined) - // One card at a time: the add card closes with whatever - // it held, since closing either card would otherwise - // discard the other's draft. - setAddOpen(false) - setEditing(open ? undefined : target) + if (open) setEditing(undefined) + else void openEditor(row, target) }} > {t('edit')} @@ -529,7 +554,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS type="button" className={styles['dangerButton']} aria-label={providerCopy(t('removeProvider'), target)} - disabled={!state.writable || state.status !== 'ready'} + disabled={editorLoading || !state.writable || state.status !== 'ready'} onClick={() => { if (controller.store.getSnapshot().status !== 'ready') return setSavedTarget(undefined) @@ -544,6 +569,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
      {error} + {credentialRead?.provider === target.provider && credentialRead.status === 'failed' + ?

      {credentialRead.message}

      : null} {renderSlot( 'settings.models.provider-card', { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, @@ -551,7 +578,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS )} {open ? renderProviderEditor({ - target, + target: { ...target, ...editing.initialCredential === undefined ? {} : { initialCredential: editing.initialCredential } }, row, namespace, schema, @@ -698,7 +725,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS - + {wide && ( + + {label} + + )} + + + {wide && canUnpin && + + } +
    ) } @@ -145,6 +165,7 @@ export function SidebarRoot({ usePanelInfo, t, renderSlot, + renderFactorySlot, }: SidebarRootComponentProps) { const panels = usePanels(snapshot => snapshot) const unpinned = useEntryPins(ids => ids) @@ -166,6 +187,10 @@ export function SidebarRoot({ }, [collapsed, drawer]) const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') const wide = drawer || (windowsTitlebar ? !collapsed : !collapsed || !settled) + const [scrollHandoff] = useState(createScrollHandoff) + const sectionHookContext = useMemo( + () => ({ useSectionScroller: scrollHandoff.useSectionScroller }), [scrollHandoff], + ) // The Windows titlebar hosts the New Session controls while the sidebar is collapsed. const openNewSession = (): void => { trackEntryClick({ menu_name: 'new_session', position: windowsTitlebar && collapsed ? 'titlebar' : 'sidebar' }) @@ -376,47 +401,57 @@ export function SidebarRoot({ )} - {panels.length > 0 && ( - - )} - - {/* The browsing region fills the column between the controls and the - foot in both states; its rail icon column rides the same slot. */} -
    - {renderSlot('sidebar.workspaces', { - wide, - expandSidebar: () => { if (collapsed) toggleSidebar() }, - })} +
    + {renderSlot('sidebar.workspaces', { + wide, + expandSidebar: () => { if (collapsed) toggleSidebar() }, + }, { hookContext: sectionHookContext })} +
    +
    +
    +
    +
    +
    +
    +
    renders Windows caption controls in expanded
    +
    +
    +
    +
    +
    renders the collapsed rail after the crossfad
    +
    +
    +
    +
    +
    renders the expanded column (wordmark, capsul
    +
    +
    +
    +
    +
    renders the expanded column in the default lo
    +
    +
    +
    +
    +
    { expect(b.slots.entries('sidebar')).toHaveLength(1) expect(b.slots.spec('sidebar.brand.mark')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.brand.name')).toEqual({ kind: 'single', scope: 'root' }) - expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) + const section = b.slots.spec('sidebar.workspaces') as { kind: string; scope: string; inject: SidebarSectionInjected } + expect(section).toMatchObject({ kind: 'single', scope: 'root' }) + // The section occupant's scroller hook is the one each sidebar render supplies. + const useSectionScroller: UseSectionScroller = () => ({ bindScrollerRef: () => {} }) + expect(section.inject.hooks.sectionScroller({} as never, { useSectionScroller })).toBe(useSectionScroller) expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' }) expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' }) expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' }) diff --git a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx index 6d3045ca09..554192b591 100644 --- a/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/panel-list.client.spec.tsx @@ -358,15 +358,23 @@ describe('sidebar launcher pins', () => { expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Beta panel', 'Alpha panel']) }) - it('shows More after the rows only while the actions list has registrations', async () => { + it('shows More and hides unpinned rows only while the actions list has registrations', async () => { const { runtime, locale, view } = await bench() const probe = await mountProbeActions(runtime) expect(view.queryByRole('navigation')).toBeNull() await mountPanels(runtime, locale) const navigation = await view.findByRole('navigation', { name: 'Global panels' }) - expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel', 'More']) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))) + .toEqual(['Beta panel', 'Unpin Beta panel from sidebar', 'Alpha panel', 'Unpin Alpha panel from sidebar', 'More']) + fireEvent.click(within(navigation).getByRole('button', { name: 'Unpin Alpha panel from sidebar' })) + expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() + expect(document.activeElement).toBe(within(navigation).getByRole('button', { name: 'More' })) await probe.dispose() await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'More' })).toBeNull() }) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel']) + await mountProbeActions(runtime) + await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() }) + expect(within(navigation).getByRole('button', { name: 'More' })).toBeTruthy() }) it.each([false, true])('opens, pins, and dismisses through the More menu with collapsed=%s', async (collapsed) => { @@ -395,6 +403,11 @@ describe('sidebar launcher pins', () => { fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Alpha panel' })) const alphaRow = await within(navigation).findByRole('button', { name: 'Alpha panel' }) expect(alphaRow.getAttribute('aria-current')).toBe('page') + fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Beta panel' })) + fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Beta panel' })) + expect(within(navigation).getAllByRole('button').map(row => row.getAttribute('aria-label')) + .filter(label => !label?.startsWith('Unpin '))) + .toEqual(['Plugins', 'Beta panel', 'Alpha panel', 'More']) fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Alpha panel' })) await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() }) expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy() diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 6e15b94baa..7b06396d72 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -50,6 +50,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
    ) as SidebarRootComponentProps['renderSlot']} />, diff --git a/packages/client/ui-sidebar/tests/scroll-handoff.client.spec.ts b/packages/client/ui-sidebar/tests/scroll-handoff.client.spec.ts new file mode 100644 index 0000000000..d1f9438ac4 --- /dev/null +++ b/packages/client/ui-sidebar/tests/scroll-handoff.client.spec.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { createScrollHandoff } from '../src/client/scroll-handoff.ts' + +/** A scroll container whose scrollTop the browser would clamp to `[0, max]`; jsdom has no layout. */ +function scrollable(max: number, clientHeight = 300): HTMLDivElement { + const element = document.createElement('div') + let top = 0 + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => top, + set: (value: number) => { top = Math.min(max, Math.max(0, value)) }, + }) + Object.defineProperty(element, 'clientHeight', { configurable: true, value: clientHeight }) + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: clientHeight + max }) + document.body.append(element) + return element +} + +function wheel(target: HTMLElement, init: WheelEventInit, timeStamp?: number): WheelEvent { + const event = new WheelEvent('wheel', { cancelable: true, bubbles: true, ...init }) + if (timeStamp !== undefined) Object.defineProperty(event, 'timeStamp', { value: timeStamp }) + target.dispatchEvent(event) + return event +} + +/** A sidebar viewport with 130px of navigation to scroll away and a section scroller with 500px of rows. */ +function bound() { + const handoff = createScrollHandoff() + const viewport = scrollable(130, 400) + const scroller = scrollable(500) + viewport.append(scroller) + handoff.bindViewportRef(viewport) + handoff.useSectionScroller().bindScrollerRef(scroller) + return { handoff, viewport, scroller } +} + +afterEach(() => { document.body.replaceChildren() }) + +describe('createScrollHandoff', () => { + it('moves the navigation away before the section scroller in one downward event', () => { + const { viewport, scroller } = bound() + expect(wheel(scroller, { deltaY: 200 }).defaultPrevented).toBe(true) + expect(viewport.scrollTop).toBe(130) + expect(scroller.scrollTop).toBe(70) + }) + + it('leaves the section scroller untouched while the navigation absorbs the distance', () => { + const { viewport, scroller } = bound() + wheel(scroller, { deltaY: 40 }) + expect(viewport.scrollTop).toBe(40) + expect(scroller.scrollTop).toBe(0) + }) + + it('keeps native scrolling inside the section scroller once the navigation is gone', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + scroller.scrollTop = 70 + expect(wheel(scroller, { deltaY: 100 }).defaultPrevented).toBe(false) + expect(wheel(scroller, { deltaY: -50 }).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(130) + expect(scroller.scrollTop).toBe(70) + }) + + it('scrolls the section scroller for wheel events that start outside it', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + expect(wheel(viewport, { deltaY: 80 }).defaultPrevented).toBe(true) + expect(scroller.scrollTop).toBe(80) + wheel(viewport, { deltaY: -50 }) + expect(scroller.scrollTop).toBe(30) + expect(viewport.scrollTop).toBe(130) + }) + + it('returns the section scroller to its top before the navigation reappears', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + scroller.scrollTop = 70 + expect(wheel(scroller, { deltaY: -100 }).defaultPrevented).toBe(true) + expect(scroller.scrollTop).toBe(0) + expect(viewport.scrollTop).toBe(100) + wheel(scroller, { deltaY: -30 }) + expect(viewport.scrollTop).toBe(70) + }) + + it('gives the viewport the rest of an uncancelable native gesture that reaches the section top', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 130 + scroller.scrollTop = 20 + expect(wheel(scroller, { deltaY: -50, cancelable: false }).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(100) + expect(scroller.scrollTop).toBe(20) + }) + + it('keeps a split gesture in JavaScript through its momentum, then returns new gestures to native scrolling', () => { + const { viewport, scroller } = bound() + viewport.scrollTop = 100 + expect(wheel(scroller, { deltaY: 60 }, 1000).defaultPrevented).toBe(true) + expect(viewport.scrollTop).toBe(130) + expect(scroller.scrollTop).toBe(30) + expect(wheel(scroller, { deltaY: 40 }, 1016).defaultPrevented).toBe(true) + expect(scroller.scrollTop).toBe(70) + expect(wheel(scroller, { deltaY: 40 }, 1200).defaultPrevented).toBe(false) + expect(scroller.scrollTop).toBe(70) + }) + + it('leaves other uncancelable events to native scrolling', () => { + const { viewport, scroller } = bound() + wheel(scroller, { deltaY: 40, cancelable: false }) + expect(viewport.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + }) + + it('converts line and page deltas to pixels', () => { + const { viewport } = bound() + wheel(viewport, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE }) + expect(viewport.scrollTop).toBe(32) + wheel(viewport, { deltaY: -0.05, deltaMode: WheelEvent.DOM_DELTA_PAGE }) + expect(viewport.scrollTop).toBe(12) + }) + + it.each([ + ['pinch-zoom', { deltaY: 40, ctrlKey: true }], + ['mostly horizontal', { deltaX: 60, deltaY: 40 }], + ['zero', { deltaY: 0 }], + ] satisfies [string, WheelEventInit][])('keeps native scrolling for %s events', (_name, init) => { + const { viewport, scroller } = bound() + expect(wheel(scroller, init).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + expect(scroller.scrollTop).toBe(0) + }) + + it('leaves events another listener already canceled', () => { + const { viewport, scroller } = bound() + scroller.addEventListener('wheel', (event) => { event.preventDefault() }, { once: true }) + wheel(scroller, { deltaY: 40 }) + expect(viewport.scrollTop).toBe(0) + }) + + it('keeps native scrolling without a bound section scroller', () => { + const { handoff, viewport } = bound() + handoff.useSectionScroller().bindScrollerRef(null) + expect(wheel(viewport, { deltaY: 40 }).defaultPrevented).toBe(false) + expect(viewport.scrollTop).toBe(0) + }) + + it('moves its listener to a rebound viewport and removes it on unbind', () => { + const { handoff, viewport } = bound() + const next = scrollable(130) + handoff.bindViewportRef(next) + expect(wheel(viewport, { deltaY: 40 }).defaultPrevented).toBe(false) + expect(wheel(next, { deltaY: 40 }).defaultPrevented).toBe(true) + handoff.bindViewportRef(null) + expect(wheel(next, { deltaY: 40 }).defaultPrevented).toBe(false) + }) + + it('hands every caller the same scroller binding', () => { + const handoff = createScrollHandoff() + expect(handoff.useSectionScroller()).toBe(handoff.useSectionScroller()) + }) +}) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index ee09e12ce5..040b994806 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -9,7 +9,7 @@ import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runt import type { ReactNode } from 'react' import { useState } from 'react' import type { - SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, + SidebarFooterActionOwnerProps, SidebarPanelMetadata, SidebarRootComponentProps, SidebarSectionHookContext, SidebarSectionOwnerProps, SidebarSettingsOwnerProps, } from '../src/client/contract/slots.ts' import { HeaderLeadingControls, type HeaderLeadingControlsProps } from '../src/client/HeaderLeadingControls.tsx' @@ -50,17 +50,19 @@ function makeHeaderProps(): HeaderLeadingControlsProps { } } -function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts = [], content }: { +function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts = [], panels = [], content }: { collapsed?: boolean width?: number drawer?: boolean shortcuts?: readonly ShortcutCatalogEntry[] + panels?: readonly SidebarPanelMetadata[] content?: ReactNode } = {}) { const startSession = vi.fn() const toggleSidebar = vi.fn() const trackEntryClick = vi.fn() let regionOwner: SidebarSectionOwnerProps | undefined + let regionContext: SidebarSectionHookContext | undefined let settingsOwner: SidebarSettingsOwnerProps | undefined let footerActionOwner: SidebarFooterActionOwnerProps | undefined const brandMark = M @@ -71,19 +73,22 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts drawer={current.drawer} collapsed={current.collapsed} width={current.width} useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} - usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useEntryPins={selector => selector([])} + usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector(panels)} useEntryPins={selector => selector([])} useEntryActions={selector => selector(false)} setPinned={vi.fn()} useShortcuts={selector => selector(shortcuts)} useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={trackEntryClick} t={t} + renderFactorySlot={() => null} renderSlot={(( key: string, owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps, + opts?: { hookContext?: SidebarSectionHookContext }, ) => { if (key === 'sidebar.brand.mark') return brandMark if (key === 'sidebar.brand.name') return brandName if (key === 'sidebar.toggle.badge') return null + if (key === 'sidebar.panellist') return if (key === 'sidebar.settings') { settingsOwner = owner return
    @@ -93,6 +98,7 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts return
    } regionOwner = owner as SidebarSectionOwnerProps + regionContext = opts?.hookContext return
    {content}
    }) as SidebarRootComponentProps['renderSlot']} /> @@ -106,6 +112,10 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts if (regionOwner === undefined) throw new Error('region owner not rendered') return regionOwner }, + regionContext: () => { + if (regionContext === undefined) throw new Error('region hookContext not rendered') + return regionContext + }, settingsOwner: () => { if (settingsOwner === undefined) throw new Error('settings owner not rendered') return settingsOwner @@ -122,6 +132,33 @@ function mountShell({ collapsed = false, width = 300, drawer = false, shortcuts } describe('SidebarRoot shell', () => { + it('puts navigation and workspaces in one viewport without scrolling primary controls or the footer', () => { + mountShell({ panels: [{ id: 'notes' as SidebarPanelMetadata['id'], order: 1, label: 'Notes' }] }) + const viewport = document.querySelector('[data-sidebar-scrollport]')! + expect(viewport.contains(screen.getByRole('navigation'))).toBe(true) + expect(viewport.contains(screen.getByTestId('region'))).toBe(true) + for (const button of screen.getAllByRole('button', { name: 'New session' })) { + expect(viewport.contains(button)).toBe(false) + } + expect(viewport.contains(screen.getByTestId('settings-seat'))).toBe(false) + expect(viewport.contains(screen.getByTestId('footer-action-seat'))).toBe(false) + }) + + it('hands the section a stable scroller hook bound to this sidebar viewport', () => { + const shell = mountShell() + const scroller = shell.regionContext().useSectionScroller() + shell.rerender({ width: 320 }) + expect(shell.regionContext().useSectionScroller()).toBe(scroller) + const viewport = document.querySelector('[data-sidebar-scrollport]')! + const unbound = new WheelEvent('wheel', { deltaY: 40, cancelable: true }) + viewport.dispatchEvent(unbound) + expect(unbound.defaultPrevented).toBe(false) + scroller.bindScrollerRef(document.createElement('div')) + const bound = new WheelEvent('wheel', { deltaY: 40, cancelable: true }) + viewport.dispatchEvent(bound) + expect(bound.defaultPrevented).toBe(true) + }) + it('advertises the effective new-session binding', () => { const shortcut: ShortcutCatalogEntry = { id: 'session.new' as ShortcutCommandId, label: 'New', aliases: [], binding: null, modified: true, conflicts: [], issue: null, keys: ['Ctrl', 'N'], aria: 'Control+N' } @@ -190,6 +227,7 @@ describe('SidebarRoot shell', () => { {...allowAllAccess} useRemoteAccess={useRemoteAccess} startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((key: string) => { if (key === 'sidebar.brand.mark') return M if (key === 'sidebar.brand.name') return Brand @@ -221,6 +259,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -245,6 +284,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -273,6 +313,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={startSession} toggleSidebar={vi.fn()} trackEntryClick={trackEntryClick} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -324,6 +365,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} />) @@ -388,6 +430,7 @@ describe('SidebarRoot shell', () => { useContextMenu={contextMenuHook()} useResource={useResource} useWorkspaces={neverHook} {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} trackEntryClick={vi.fn()} t={t} + renderFactorySlot={() => null} renderSlot={((key: string) => key === 'sidebar.toggle.badge' ? : null) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts index 67efc3c945..3ed16573ec 100644 --- a/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts +++ b/packages/client/ui-sidebar/tests/sidebar-styles.client.spec.ts @@ -31,27 +31,64 @@ describe('SidebarRoot.module.css', () => { expect(declarations('.collapsed .panelList')?.get('gap')).toBe('12px') }) - it('shares and cancels the wide shell trailing padding structurally', () => { + it('fits expanded navigation into 32px rows while retaining 36px rail controls', () => { + const row = declarations('.panelRow') + expect(row?.get('min-height')).toBe('32px') + expect(row?.get('padding')).toBe('5px calc(var(--dsh-sidebar-row-padding) - var(--dsh-sidebar-paint-inset))') + expect(row?.get('line-height')).toBe('22px') + expect(row?.get('box-sizing')).toBe('border-box') + expect(row?.get('border')).toBe('none') + expect(declarations('.collapsed .panelRow')?.get('width')).toBe('36px') + expect(declarations('.collapsed .panelRow')?.get('height')).toBe('36px') + expect(declarations('.collapsed .panelRow')?.get('padding')).toBe('0') + }) + + it('owns a shared scrollport whose gaps scroll away', () => { const root = declarations('.root') + const scrollport = declarations('.scrollArea') expect(root?.get('--dsh-sidebar-inline-padding')).toBe('8px') expect(root?.get('--dsh-sidebar-row-padding')).toBe('12px') expect(root?.get('--dsh-sidebar-label-gap')).toBe('6px') expect(root?.get('--dsh-sidebar-paint-inset')).toBe('3px') expect(root?.get('padding')).toBe('6px var(--dsh-sidebar-inline-padding)') - expect(declarations('.regionArea')?.get('margin-left')).toBe('-4px') - expect(declarations('.regionArea')?.get('padding-left')).toBe('4px') - expect(declarations('.regionArea')?.get('margin-right')).toBe( - 'calc(-1 * var(--dsh-sidebar-inline-padding))', - ) - expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBe('0') - expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0') - expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0') + expect(declarations('.scrollRegion')?.get('min-height')).toBe('0') + expect(declarations('.root:not(.collapsed) .scrollRegion')?.get('container-type')).toBe('size') + expect(declarations('.scrollRegion')?.get('container-type')).toBeUndefined() + expect(scrollport?.get('display')).toBe('grid') + expect(scrollport?.get('overflow-y')).toBe('auto') + expect(scrollport?.get('overflow-x')).toBe('hidden') + expect(scrollport?.get('grid-template-columns')).toBe('4px minmax(0, 1fr) calc(var(--dsh-sidebar-inline-padding) - 2px)') + expect(scrollport?.get('grid-template-rows')).toBe('8px max-content 2px max-content') + expect(declarations('.scrollRegion')?.get('margin-top')).toBe('4px') + expect(declarations('.collapsed .scrollRegion')?.get('margin-top')).toBe('0') + expect(scrollport?.get('margin-left')).toBe('-4px') + expect(scrollport?.get('margin-right')).toBe('calc(2px - var(--dsh-sidebar-inline-padding))') + expect(scrollport?.get('scroll-padding-block')).toBeUndefined() + expect(declarations('.panelList')?.get('grid-row')).toBe('2') + expect(declarations('.regionArea')?.get('grid-row')).toBe('4') + expect(declarations('.regionArea')?.get('overflow')).toBeUndefined() + expect(declarations('.regionArea')?.get('margin-right')).toBeUndefined() + expect(declarations('.regionArea')?.get('flex')).toBeUndefined() + expect(declarations('.collapsed .regionArea')?.get('flex')).toBe('1') + expect(declarations('.collapsed .regionArea')?.get('margin-left')).toBeUndefined() + expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBeUndefined() + expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBeUndefined() + expect(declarations('.scrollFade')?.get('height')).toBe('24px') + expect(declarations('.collapsed .scrollArea')?.get('overflow')).toBe('visible') + expect(declarations('.collapsed .scrollArea')?.get('margin')).toBe('0') + }) + + it('hides the expanded viewport scrollbar and publishes its scroll state to the section', () => { + const expanded = declarations('.root:not(.collapsed) .scrollArea') + expect(expanded?.get('container-type')).toBe('scroll-state') + expect(expanded?.get('scrollbar-width')).toBe('none') + expect(declarations('.root:not(.collapsed) .scrollArea::-webkit-scrollbar')?.get('display')).toBe('none') }) it('keeps the reviewed expanded spacing separate from rail and caption controls', () => { expect(declarations('.logoRow')?.get('padding')).toBe('8px 0 8px 8px') expect(declarations('.logoRow')?.get('margin-bottom')).toBe('0') - expect(declarations('.newSession')?.get('margin')).toBe('0 calc(var(--dsh-sidebar-paint-inset) + 1px) 10px') + expect(declarations('.newSession')?.get('margin')).toBe('0 calc(var(--dsh-sidebar-paint-inset) + 1px)') expect(declarations('.newSessionContent')?.get('justify-content')).toBe('center') expect(declarations(":global(html[data-platform='darwin']:not([data-fullscreen])) .root:not(.collapsed) > .topStrip > .toggle")?.get('transform')).toBe('translateX(4px)') expect(css).not.toContain(':not(.fading)') diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index b12b090972..310cd7839b 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -15,14 +15,14 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-subagent/use-this-package: - en: 9aafcfbd6a33173c - zh: 6dbd48672f58cd2d + en: d00a6c7ffced5dc7 + zh: 616220bbdfdc70a6 /deepseek-ai-dsh-client-ui-subagent/use-this-package/browsing-the-tree: - en: 3508631091820123 - zh: ec7da9e5bedb22ba + en: 4ecdca030e42109d + zh: 406b08590ba8df83 /deepseek-ai-dsh-client-ui-subagent/use-this-package/continuing-a-conversation: - en: 876e7152c8b9c4a6 - zh: f89f5e2a812e8451 + en: e4ec261d3b122eb5 + zh: 320bb2efc93dccdc /deepseek-ai-dsh-client-ui-subagent/use-this-package/the-reference-source: en: 6a5f91b7dfcefafe zh: 2d11441952cfea53 @@ -30,14 +30,14 @@ en: c158f12dcaed87b7 zh: e5d315e7b1fb4fa9 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation: - en: 5531dcc1fb976cff - zh: 6fe779f4f1c34266 + en: 41492c7bf30f2a48 + zh: b225f51bd226d60d /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens: - en: b817e9907392f59c - zh: e42feb97d572a989 + en: dcbc58d4cad47fa8 + zh: e962a8cbdfd4a914 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/composer-election: - en: 13c33af89ba0942e - zh: 00374c7944728ae2 + en: 8ff34f93e40fbdc2 + zh: cc3430130c9df0a4 /deepseek-ai-dsh-client-ui-subagent/further-exploration: en: 1766e9012e307178 zh: f3ee637f5941aae5 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index ad56362a7a..18bee2a024 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -25,19 +25,25 @@ Browse local subagent conversations and external tasks from the parent header. L ## Use this package -The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders after the preset label in the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. +The session header uses the session title for ordinary breadcrumbs. Subagent breadcrumbs prefer the original creation description (`entry.label`), then the Session id. Ordinary and subagent breadcrumb titles both use 14px text with a 20px line height. When the session's direct catalog has entries or a read has failed, the descendant-count trigger renders after the preset label in the header actions band, with no breadcrumb separator. An absent or empty catalog hides the count trigger unless the root Session has an enabled, available Team board with shared tasks or a task-record error. Enabling Team mode alone does not show an empty entry. The trigger opens that direct catalog, uses the total count as its accessible name and the running count as its accessible description, and loads nested catalogs only when their rows expand. Select a local child at any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use a noncurrent local row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. +Every open catalog menu is 360px wide with 6px outer padding, constrained to fit the viewport. The root menu shows noncollapsible Subagents and Shared tasks headers only when Team mode is enabled, at least one nondeleted shared task exists, and the footer contribution is available. The Subagents header uses a Users icon and counts direct entries in completed, in progress, not running, and external order; zero counts are omitted and the separator matches the input task list. External tasks never count as running or completed. An empty Subagents section shows “No subagents” only alongside shared tasks. With Team mode off or no shared tasks, the menu shows only the subagent list, without section headers, a shared-task module, empty-state copy, or a divider; a real task-record failure remains a separate alert. Shared tasks have a 4px top margin rather than a divider. Both section headers keep a 7px icon/title gap and an additional 4px before their counts. Only the menu scrolls; sections have no nested scrolling. In WebKit-compatible browsers, the native 5px scrollbar sits in the right 6px inset with inset track ends and a stable gutter, preserving content width when scrolling becomes necessary. Other browsers retain their standard native scrollbar. + +The root catalog exposes `conversation.session.header.actions.subagent.footer` for shared tasks. Child and ancestor breadcrumb catalogs show navigation only, without section headers. + +The wide root count trigger shows a 14px Users icon in its 14px slot when no child is running and the shared ongoing loader otherwise, without a dropdown arrow. As the title row narrows, passive preset text hides at widths of 540px or less and the count text hides at 460px or less. The compact trigger always shows the Users icon, including while children are running; running indicators remain inside the menu. It matches More and sidebar controls: a 28×28px action with no padding, a 15px glyph in a 15px slot, secondary ink, and ghost hover and active backgrounds. Header actions use an 8px gap in compact layout and 10px in wide layout. The icon-only tooltip exposes the full count. Resizing preserves focus on the trigger. Subagent switchers retain their description-first text and existing icon. + ### Browsing the tree -Hovering a trigger opens its catalog after 150ms; leaving both trigger and catalog closes it after 120ms. Clicking the descendant-count trigger pins its catalog until outside click or Escape from the trigger or tree. Breadcrumb-title clicks navigate to the corresponding conversation. +Count, owner, and sidebar-action tooltips appear after 500ms of hover. Hovering a text-visible trigger opens its catalog after 150ms; leaving both trigger and catalog closes it after 120ms. Clicking the descendant-count trigger opens and pins its catalog, or closes an already pinned catalog. The icon-only count trigger does not open on hover, including when a pending hover crosses into the narrow layout. Clicking toggles its pinned menu; ArrowDown opens and pins the menu and focuses its first row. Outside click or Escape dismisses a pinned menu. Clicking moves focus into the panel, where ArrowDown/Home enters the first tree row and ArrowUp/End enters the last; Escape returns focus when it was inside. Breadcrumb-title clicks navigate to the corresponding conversation. -Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty. +Rows show the creation description, or an available Session title, above the Session id. Without either description, the row shows only its Session id. Local rows use compact 4px vertical padding; their clickable content uses 6px horizontal padding and 7px gaps, aligned with shared-task rows while retaining the tree's left offset. A decorative trailing check marks the current conversation and is not an action; other local rows retain the sidebar arrow. Primary titles use 13px text and secondary ids use 12px. All rows wrap descriptions and ids in full, including on touch screens. Running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The text-visible header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty. ### Continuing a conversation -A continuable child with a live parent keeps the ordinary input chrome: typing and Send stay available while the child runs because every follow-up joins the child's FIFO inbox, and an independent Stop routes through `subagents/interruptByParent`. A continuable child whose exact parent is unavailable and which is not running elects a read-only composer explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer with input and Send disabled but its independent Stop usable. +A continuable child keeps the ordinary input and independent Stop action regardless of creator availability. Prompts route through `subagents/prompt`, whose runtime validates the creation address and live Team root; failures use ordinary prompt feedback. Stop routes through `subagents/interruptByUser`. ### The `@` reference source @@ -55,7 +61,9 @@ The [subagent runtime](../../subagent/subagent/README.md) owns continuation and ### Catalog derivation -The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row reads its target's projections, including ordinary cold history. A list row with `formatStatus: migration-required` displays “Migration required” before expansion and keeps its history-opening actions without descendant expansion or changing its subagent mode; a projection read requiring migration without a catalog supplies the same notice. Opening the Session supplies its catalog through history and control projections. Otherwise, an available catalog remains usable, including an explicit empty catalog. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Other rows remain expandable while their catalogs are absent, loading, or failed, and become known leaves after ready empty catalogs. External rows are always leaves and do not load child Sessions. +The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts. Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row reads its target's projections, including ordinary cold history. A list row with `formatStatus: migration-required` displays “Migration required” before expansion and keeps its history-opening actions without descendant expansion or changing its subagent mode; a projection read requiring migration without a catalog supplies the same notice. Opening the Session supplies its catalog through history and control projections. Otherwise, an available catalog remains usable, including an explicit empty catalog. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Other rows remain expandable while their catalogs are absent, loading, or failed, and become known leaves after ready empty catalogs. External rows are always leaves and do not load child Sessions. + +Each header entry declares a footer slot below its tree. Optional UI contributes there within the same scroll container; the catalog owns opening and dismissal. Shared anchored positioning keeps the panel inside the visible viewport when its content, the window size, or browser zoom changes. Opening a catalog dropdown does not request its root catalog. Child-catalog expansion and failed-read retries call `refreshProjection`; shared projection-value changes update the display automatically. @@ -63,15 +71,15 @@ Breadcrumb addresses derive from the Provider-bound Session address, loaded pare ### Duration, completion, and tokens -Each visible catalog level advances its own clock while it contains a running child; collapsing the level or closing the menu releases that clock. Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and records whether the latest closed turn ended with `completed`; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. +Each visible catalog level advances its own clock while it contains a running child; collapsing the level or closing the menu releases that clock. Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and supplies `latestTurnCompleted` for both the success dot and completed count; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. ### Composer election -One-shot children always elect a read-only composer. A continuable child elects one only when its exact parent is unavailable and the child is not running; otherwise the ordinary composer's Session routes prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. +One-shot and unknown-mode children elect a read-only composer. Continuable children route prompts through `subagents/prompt`. This package never receives host context or calls a model-facing tool. Unknown-mode catalog rows remain visible and clickable, using the child id when no label is available. Their composer is read-only until child history establishes a supported mode; a read failure is reported in the child conversation. -Lineage-menu highlights follow the [standard menu edge geometry](../../../docs/ui-radius.md#nested-hover); tree disclosure controls retain their separate left gutter. +Lineage-menu highlights follow the [standard menu edge geometry](../../../docs/ui-radius.md#nested-hover). Each branch disclosure target covers its 28px left gutter and full row height without overlapping adjacent rows; only the 12px glyph rotates in place, and disclosure clicks do not trigger row or sidebar navigation. Disclosure hover highlights only its arrow; navigation-body hover and keyboard focus retain their background highlight. Thin level-3 border connectors align with the first-line glyph centers, cross reserved disclosure space to reach leaf status glyphs, and end the final vertical stem at its glyph center. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 1c2048920b..56a37645db 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,19 +25,25 @@ kind: "package-reference" ## 使用本包 -会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区中预设标签之后,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 +会话页头的普通面包屑使用会话标题。subagent 面包屑优先使用创建时的原始描述(`entry.label`),缺少时回退到 Session id。普通与 subagent 面包屑标题均使用 14px 文字和 20px 行高。当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区中预设标签之后,不带任何面包屑分隔符。目录缺席或为空时隐藏数量触发器,除非根 Session 已开启且可用的 Team 任务板有共享任务或任务记录错误。仅开启 Team 模式不会显示空入口。触发器打开该直接目录,以总数作为无障碍名称、运行数作为无障碍描述,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用非当前本地行的行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 +每个打开的目录菜单宽度均为 360px,外层内边距为 6px,并受视口可用宽度约束。仅当 Team 模式已开启、存在至少一个未删除的共享任务且 footer 扩展可用时,根菜单才显示不可折叠的“子智能体”和“共享任务”标题。“子智能体”标题使用 Users 图标,仅统计直接条目,依次显示已完成、进行中、未运行和外部任务数量;省略零值,分隔符与输入区任务列表一致。外部任务不计入运行中或已完成数量。空的“子智能体”区域仅在同时展示共享任务时显示“暂无子智能体”。Team 模式关闭或没有共享任务时,菜单仅显示 subagent 列表,不显示区域标题、共享任务模块、空状态文案或分隔线;真实的任务记录错误仍以独立警告显示。共享任务上方保留 4px 外边距,不显示分隔线。两个区域标题均保留 7px 图标与文字间距,并在统计数量前额外留出 4px。仅菜单本身滚动,各区域不单独滚动。在兼容 WebKit 的浏览器中,原生 5px 滚动条位于右侧 6px 内边距内,轨道两端内缩,并保留稳定的滚动条空间,使内容在需要滚动时保持原有宽度。其他浏览器保留标准原生滚动条。 + +根会话目录通过 `conversation.session.header.actions.subagent.footer` 提供共享任务扩展。子会话与祖先面包屑目录只显示导航,不显示区域标题。 + +宽布局下,根会话数量触发器在没有运行中 child 时,于 14px 图标位内显示 14px Users 图标,否则显示共享 ongoing loader,不带下拉箭头。标题行变窄时,静态预设文字在宽度不超过 540px 时隐藏,数量文字在不超过 460px 时隐藏。紧凑触发器始终显示 Users 图标,包括 child 运行期间;运行指示器仍在菜单内显示。它与更多及侧边栏控件一致:28×28px 操作区域,无内边距,15px 图标位内显示 15px 图标,使用次级前景色以及 ghost 悬停和按下背景。页头操作在紧凑布局中间距为 8px,宽布局中为 10px。纯图标工具提示提供完整数量。调整宽度不会丢失触发器焦点。subagent 切换器保留描述优先的文本与原有图标。 + ### 浏览目录 -悬停触发器 150ms 后打开目录;指针离开触发器和目录后,经过 120ms 关闭。点击后代数量触发器会固定目录,直到点击外部,或在触发器或树内按 Escape 关闭。点击面包屑标题会导航至对应会话。 +数量、负责人及侧边栏操作的工具提示在悬停 500ms 后显示。悬停文字可见的触发器 150ms 后打开目录;指针离开触发器和目录后,经过 120ms 关闭。点击后代数量触发器会打开并固定目录,或关闭已固定的目录。纯图标数量触发器不会因悬停而打开,包括等待悬停打开期间切换到窄布局的情况。点击可切换固定菜单的开关状态;ArrowDown 会打开并固定菜单,并将焦点移至首行。点击外部或按 Escape 可关闭已固定的菜单。点击会将焦点移入面板,此时 ArrowDown/Home 进入首个树行,ArrowUp/End 进入最后一个树行;Escape 在焦点原本位于面板内时将其送回。点击面包屑标题会导航至对应会话。 -行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。 +目录行在 Session id 上方显示创建描述,缺少创建描述时使用可用的 Session title。两者均缺少时,该行仅显示 Session id。本地行使用紧凑的 4px 垂直内边距;可点击内容使用 6px 水平内边距和 7px 间距,与共享任务行对齐,同时保留树的左侧偏移。当前对话在行尾显示装饰性对勾,该对勾不是操作控件;其他本地行保留侧边栏箭头。主标题使用 13px 文字,次级 id 使用 12px。所有行均将描述与 id 换行完整展示,包括在触屏上。running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。文字可见的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。 ### 续接对话 -确切 parent 存活时,可继续 child 保留普通输入 chrome:child 运行期间输入和 Send 保持可用,因为每条后续消息都会进入 child 的 FIFO inbox,而独立的 Stop 经由 `subagents/interruptByParent` 路由。确切 parent 不可用且 child 未在运行的可继续 child 会选用说明恢复路径的只读编辑器;此类 child 仍在运行期间,selector 会让位给普通编辑器——输入区与 Send 被禁用,但独立的 Stop 保持可用。 +可继续 child 保留普通输入和独立 Stop 操作,不受创建者在线状态影响。提示词经由 `subagents/prompt` 路由,由运行时校验创建地址和在线 Team 根;失败使用普通提示词错误反馈。Stop 经由 `subagents/interruptByUser` 路由。 ### `@` 引用 source @@ -55,7 +61,9 @@ kind: "package-reference" ### 目录派生 -页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行读取目标投影,包括普通冷历史。列表行的 `formatStatus` 为 `migration-required` 时,行在展开前即显示“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode;投影读取需要迁移且缺少目录时,也显示同一提示。打开会话后,历史与 control 投影提供其目录。其他情况下,已有目录仍可使用,包括显式空目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。其他行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。 +页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量。活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行读取目标投影,包括普通冷历史。列表行的 `formatStatus` 为 `migration-required` 时,行在展开前即显示“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode;投影读取需要迁移且缺少目录时,也显示同一提示。打开会话后,历史与 control 投影提供其目录。其他情况下,已有目录仍可使用,包括显式空目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。其他行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。 + +每个页头入口在树下方声明 footer 插槽。可选 UI 通过该插槽加入同一滚动容器;目录负责打开和关闭。共享锚点定位会在内容、窗口大小或浏览器缩放变化时,将面板限制在可见视口内。 打开目录下拉菜单不会请求其根目录。展开子节点目录或重试失败读取时调用 `refreshProjection`;共享投影值的变化会自动更新显示。 @@ -63,15 +71,15 @@ kind: "package-reference" ### 耗时、完成状态与 token -每个可见目录层在包含运行中 child 时独立推进时钟;折叠该层或关闭菜单会释放时钟。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。 +每个可见目录层在包含运行中 child 时独立推进时钟;折叠该层或关闭菜单会释放时钟。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并提供 `latestTurnCompleted`,用于成功圆点与已完成数量;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。 ### 编辑器选举 -one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器;否则普通编辑器的会话会经 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 +one-shot 和模式未知的 child 选用只读编辑器。可继续 child 经由 `subagents/prompt` 路由提示词。本包绝不接收宿主上下文,也不调用面向模型的工具。 模式未知的目录项仍可见、可点击,没有标签时显示子会话 id。读取子历史并确认受支持模式前,输入区保持只读;读取失败在该子会话中报告。 -会话树菜单高亮遵循[标准菜单边缘几何](../../../docs/ui-radius.zh.md#nested-hover);树形展开控件保留独立的左侧空间。 +会话树菜单高亮遵循[标准菜单边缘几何](../../../docs/ui-radius.zh.md#nested-hover)。每个分支展开控件的点击区域覆盖左侧 28px 空间与所在行的完整高度,不与相邻行重叠;仅原位的 12px 图标旋转,点击展开控件不会触发行导航或侧边栏导航。悬停展开控件仅高亮箭头;悬停导航主体或键盘聚焦时仍保留背景高亮。细连接线使用三级边框颜色,与首行图标中心对齐,跨过预留的展开控件空间连接叶子状态图标,末端竖线止于对应图标中心。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 93f40eef08..a87b7df460 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -70,7 +70,8 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:*" + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-agent-team": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 1abef93da0..820ee6aef4 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -34,6 +34,8 @@ gap: 4px; min-width: 0; max-width: 244px; + font-size: 14px; + line-height: 20px; color: var(--dsw-alias-label-primary); font-weight: 500; } @@ -94,13 +96,57 @@ } } -.trigger svg, .switcherTrigger svg { transition: transform 120ms ease; } -.triggerOpen { - transform: rotate(180deg); +.trigger .compactIcon { + display: none; +} + +/* The title-row container drops passive preset text at 540px; this action yields later. */ +@container (max-width: 460px) { + .trigger .compactIcon { + display: block; + } + + .trigger .textModeActivity { + display: none; + } + + .trigger .count { + display: none; + } + + .trigger { + box-sizing: border-box; + width: 28px; + height: 28px; + padding: 0; + justify-content: center; + color: var(--dsw-alias-label-secondary); + } + + .trigger .activitySlot, + .trigger .activitySlot > svg { + width: 15px; + height: 15px; + } + + .trigger:focus-visible { + color: var(--dsw-alias-label-secondary); + } + + .trigger:active { + background: var(--dsw-alias-interactive-bg-active); + } + + @media (hover: hover) { + .trigger:hover { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); + } + } } .menu { @@ -109,45 +155,82 @@ box-sizing: border-box; display: flex; flex-direction: column; - width: 336px; - max-width: min(400px, calc(100vw - 32px)); - max-height: min(560px, calc(100vh - 140px)); - padding: 4px; + width: 360px; + max-width: min(calc(100vw - 32px), var(--dsh-anchored-max-width, 100vw)); + max-height: min(560px, calc(100vh - 140px), var(--dsh-anchored-max-height, 100vh)); + padding: 6px; overflow: hidden; - border-radius: var(--dsw-radius-lg); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } -/* The menu skin lives on a pseudo-element: backdrop-filter on the menu itself - would turn it into the containing block for fixed descendants, so the - sidebar-button Tooltip bubbles would be re-anchored into the menu and - clipped by its hidden overflow. */ -.menu::before { - content: ''; - position: absolute; - z-index: -1; - inset: 0; - border-radius: inherit; - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); -} - .menuBody { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: auto; + scrollbar-gutter: stable; + --dsh-scrollbar-track-margin: 6px; +} + +/* Keep the shared WebKit scrollbar in the menu's right inset, not its content column. */ +@supports selector(::-webkit-scrollbar) { + .menuBody { + margin-right: calc(-1 * var(--dsh-scrollbar-width)); + } +} + +.sectionHeader { + display: flex; + align-items: flex-start; + gap: 7px; + padding: 4px 7px 8px; +} + +.sectionIcon { + display: grid; + flex: none; + place-items: center; + width: 14px; + height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +.sectionTitle { + flex: none; + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-weight: 500; + line-height: 24px; +} + +.sectionProgress { + margin-inline-start: 4px; + flex: 1 1 auto; + min-width: 0; + padding-top: 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + overflow-wrap: anywhere; +} + +.sectionEmpty { + margin: 0; + padding: 0 7px 8px 28px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; } .node { position: relative; min-width: 0; } -.menuBody > .node { +.menuBody > [role="tree"] > .node { margin-left: -2px; } @@ -155,11 +238,11 @@ position: relative; display: flex; align-items: flex-start; - gap: 6px; + gap: 7px; box-sizing: border-box; width: 100%; - min-height: 44px; - padding: 6px 7px 6px 9px; + min-height: 40px; + padding: 4px 6px 4px 9px; border: 0; border-radius: var(--dsw-radius-md); background: transparent; @@ -176,7 +259,7 @@ } @media (hover: hover) { - .row:hover > .clickarea { + .clickarea:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -187,10 +270,10 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 6px; + gap: 7px; min-width: 0; - margin: -6px -7px -6px; - padding: 6px 7px; + margin: -4px -6px -4px; + padding: 4px 6px; border-radius: var(--dsw-radius-md); } @@ -211,6 +294,7 @@ } .disclosure { + position: static; display: inline-flex; align-items: center; justify-content: center; @@ -219,12 +303,23 @@ background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; - transition: transform 120ms ease; +} + +/* The row owns this hit area; it ends before the status glyph and never rotates. */ +.disclosure::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 28px; + z-index: 1; } .disclosure svg { width: 12px; height: 12px; + transition: transform 120ms ease; } @media (hover: hover) { @@ -233,7 +328,7 @@ } } -.disclosureOpen { +.disclosureOpen svg { transform: rotate(90deg); } @@ -245,19 +340,24 @@ } .label, +.secondaryLabel, .summary { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .label { color: inherit; + font-size: 13px; font-weight: 400; } .currentLabel { - font-weight: 600; + font-weight: 500; +} + +.secondaryLabel { + color: var(--dsw-alias-label-tertiary); } .summary, @@ -294,6 +394,12 @@ margin: 4px 0; } +.currentCheck { + display: grid; + place-items: center; + color: var(--dsw-alias-label-primary); +} + .sidebarButton { display: inline-flex; align-items: center; @@ -319,6 +425,8 @@ } .children { + /* First-line marker center: row inset 4px + half of the 17px status column. */ + --branch-anchor-y: 12.5px; position: relative; margin-left: 16px; padding-left: 3px; @@ -329,7 +437,7 @@ content: ''; position: absolute; left: 0; - border-left: 0.5px solid var(--dsw-alias-border-l2); + border-left: 0.5px solid var(--dsw-alias-border-l3); } .children::before { @@ -349,16 +457,21 @@ .children > .node:last-child::before { bottom: auto; - height: 15px; + height: var(--branch-anchor-y); } .children > .node > .row::before { content: ''; position: absolute; - top: 14px; + top: calc(var(--branch-anchor-y) - 0.25px); left: -3px; width: 12px; - border-top: 0.5px solid var(--dsw-alias-border-l2); + border-top: 0.5px solid var(--dsw-alias-border-l3); +} + +/* A leaf beside expandable siblings keeps their disclosure column before its status marker. */ +.children > .node > .row:has(> .disclosureSpace)::before { + width: 33px; } .notice, diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index e3d87c3267..183e2d66ab 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from 'react' +import { useMemo, useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { type SessionProjectionMap, type SessionSummary, @@ -7,12 +7,15 @@ import { import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot, Tooltip, + IconChevronRightOutlineRegular, IconRefreshOutlineRegular, IconUsersOutlineRegular, + IconCheckOutlineRegular, MenuSurface, StateDot, Tooltip, useAnchoredPosition, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-token-meter/client' +import type {} from '@deepseek-ai/dsh-agent-team/client' +import type {} from './slots.ts' import css from './SubagentHeaderLineage.module.css' type SubagentCatalogSnapshot = Omit & { @@ -22,15 +25,23 @@ type SubagentCatalogSnapshot = Omit> /** Business actions supplied by the slot registration. */ -export interface SubagentCatalogInjected { +export type SubagentCatalogInjected = { openChild: (address: SubagentAddress) => void openChildAside: (address: SubagentAddress) => void refreshProjection: (parentSessionId: SessionId) => void } +/** Root catalog actions and availability of its optional footer. */ +export type SubagentCatalogActionInjected = SubagentCatalogInjected & { + hooks: { + /** Whether the catalog footer currently has a registered contributor. */ + footerAvailable: HostObservable + } +} + /** Full props for the session-header lineage renderer. */ export type SubagentHeaderLineageProps = - PropsRuntime<'conversation.session.header.lineage'> & SubagentCatalogInjected & PropsLocale + PropsRuntime<'conversation.session.header.lineage'> & InjectFace & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -197,6 +208,32 @@ function isKnownLeaf(catalog: SubagentCatalogSnapshot | undefined): boolean { return catalog?.state === 'ready' && catalog.entries.length === 0 } +/** Only a local child's last closed, normally completed turn receives the completed marker. */ +function completedEntry(entry: SubagentCatalogSnapshot['entries'][number], summary: SessionSummary | undefined): boolean { + return entry.mode !== 'external' && entry.activity === 'inactive' + && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true +} + +/** Count direct catalog members without treating external or other inactive entries as completed. */ +function catalogProgress(entries: SubagentCatalogSnapshot['entries'], summaries: CatalogRowsProps['summaries'], t: TranslateNS): string { + let completed = 0 + let active = 0 + let inactive = 0 + let external = 0 + for (const entry of entries) { + if (entry.mode === 'external') external++ + else if (entry.activity === 'running') active++ + else if (completedEntry(entry, summaries[entry.id])) completed++ + else inactive++ + } + return [ + ...completed > 0 ? [t('progress.completed', { count: completed })] : [], + ...active > 0 ? [t('progress.active', { count: active })] : [], + ...inactive > 0 ? [t('progress.inactive', { count: inactive })] : [], + ...external > 0 ? [t('progress.external', { count: external })] : [], + ].join('\u2002·\u2002') +} + /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, @@ -244,17 +281,18 @@ function CatalogRows({ const childLoading = childCatalog === undefined || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] - const label = entry.label ?? entry.id + const description = entry.label || summary?.title + const primaryLabel = description || entry.id + const secondaryLabel = description ? entry.id : undefined const mode = external ? t('mode.external') : entry.mode === 'unknown' ? t('mode.unknown') : entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') - const completed = !external && entry.activity === 'inactive' - && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true + const completed = completedEntry(entry, summary) const activity = external ? undefined : entry.activity === 'running' ? t('activity.running') : completed ? t('activity.completed') : t('activity.inactive') - const secondary = [summary?.title, requiresOpening ? t('catalog.migrationRequired') : mode, activity] + const secondary = [secondaryLabel, requiresOpening ? t('catalog.migrationRequired') : mode, activity] .filter(value => value !== undefined) .join(' · ') const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) @@ -313,7 +351,7 @@ function CatalogRows({ aria-disabled={external || undefined} aria-level={level} aria-current={isCurrent || undefined} - aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} + aria-label={[primaryLabel, secondary, metrics].filter(value => value !== '').join(' ')} {...canExpand ? { 'aria-expanded': isExpanded } : {}} className={css.row} onClick={open} @@ -326,7 +364,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} + aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label: primaryLabel })} onClick={toggle} > @@ -337,8 +375,11 @@ function CatalogRows({ - {label} - {secondary} + {primaryLabel} + {secondaryLabel !== undefined && ( + {secondaryLabel} + )} + {requiresOpening && {t('catalog.migrationRequired')}} {metrics !== '' && ( @@ -353,14 +394,18 @@ function CatalogRows({ )} )} - {isCurrent || external - ? - : ( - + {isCurrent + ? ( + + + + ) + : external ? : ( + + {t(totalCountKey, { count: directCount })} + + )} + {variant === 'switcher' && } + + + {variant === 'count' && runningCount > 0 && ( + + )} {open && createPortal(( -
    { if (event.pointerType !== 'touch') cancelHoverClose() }} onPointerLeave={scheduleHoverClose} > -
    - { changeOpen(false) }} - t={t} - /> +
    + {hasSharedTasks && ( +
    + +

    {t('section.title')}

    + {progress && {progress}} +
    + )} + {hasSharedTasks && presentedCatalog.state === 'ready' && directCount === 0 && ( +

    {t('section.empty')}

    + )} +
    + { changeOpen(false) }} + t={t} + /> +
    + {props.renderFooter?.()}
    -
    + ), document.body)}
    ) @@ -753,7 +815,8 @@ function CatalogDropdown({ /** Full props for the root-session catalog entry in the header actions band. */ export type SubagentCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale + PropsRuntime<'conversation.session.header.actions'> & InjectFace & PropsLocale + & Omit, 'SessionProvider'> /** * Session-header catalog action for root sessions: the descendant count and @@ -763,16 +826,30 @@ export type SubagentCatalogActionProps = * @param props - Session standard props plus the catalog actions and translator. * @returns The count dropdown, or null on a child session. */ -export function SubagentCatalogAction({ - sessionId, useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t, +export function SubagentCatalogAction(props: SubagentCatalogActionProps) { + const addressedChild = props.useSession(snapshot => snapshot.subagent !== null) + const listedChild = props.useSessions(state => state.byId[props.sessionId]?.origin === 'subagent') + if (addressedChild || listedChild) return null + return +} + +/** Bind task-board visibility only in root conversations. */ +function RootCatalogAction({ + sessionId, useProjection, useSessions, useSessionStatus, useFooterAvailable, + openChild, openChildAside, refreshProjection, t, renderSlot, }: SubagentCatalogActionProps) { - const isChild = useSessions(state => state.byId[sessionId]?.origin === 'subagent') - if (isChild) return null + const hasSharedTasks = useProjection('agentTeam', team => team?.enabled === true + && team.tasks.some(task => task.status !== 'deleted')) + const hasTaskFailure = useProjection('agentTeam', team => team?.enabled === true && team.failure !== undefined) + const footerAvailable = useFooterAvailable(value => value) return ( renderSlot('conversation.session.header.actions.subagent.footer', {})} useSessions={useSessions} useSessionStatus={useSessionStatus} openChild={openChild} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 07feb84f55..aacbea6afe 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -4,7 +4,7 @@ import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ export interface SubagentReadOnlyMatch { - reason: 'one-shot' | 'parent-unavailable' | 'unknown' + reason: 'one-shot' | 'unknown' } /** Full chain props after the read-only subagent selector accepts the owner currency. */ @@ -24,7 +24,7 @@ export function SubagentReadOnlyComposer({
    {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {t(matched.reason === 'unknown' ? 'readonly.unknown.body' : oneShot ? 'readonly.oneShot.body' : 'readonly.body')} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.unknown.body')}
    ) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 117b0b0605..a84992246d 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -3,7 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { SubagentCatalogAction, SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentHeaderLineage.tsx' +import { SubagentCatalogAction, SubagentHeaderLineage, type SubagentCatalogInjected, type SubagentCatalogActionInjected } from './SubagentHeaderLineage.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' @@ -15,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import { en, ja, es, fr, de, ru, pt, NS, zh, type SubagentKey } from './locales.ts' +export type * from './slots.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -24,7 +25,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } export type { - SubagentCatalogActionProps, SubagentCatalogInjected, SubagentHeaderLineageProps, + SubagentCatalogActionProps, SubagentCatalogInjected, SubagentCatalogActionInjected, SubagentHeaderLineageProps, } from './SubagentHeaderLineage.tsx' export type { SubagentReadOnlyComposerProps, SubagentReadOnlyMatch, @@ -33,19 +34,13 @@ export type { /** Required services for subagent presentation and navigation. */ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'] -/** Claim the composer for one-shot history or an unavailable continuation owner. */ +/** Claim the composer when the child mode does not allow follow-ups. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null if (subagent.address.mode === 'unknown') return { reason: 'unknown' } if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } - // Until a Host summary establishes parent availability, keep the normal - // disabled composer instead of claiming that the parent is offline. - if (subagent.parentAvailable !== false) return null - // A RUNNING parent-offline continuable child keeps the default composer: - // its input is disabled there, but the same primary Stop stays available so - // the child can be interrupted. Once it stops, this takeover returns. - return owner.session?.running === true ? null : { reason: 'parent-unavailable' } + return null } /** @@ -71,6 +66,10 @@ export function apply(ctx: ClientContext): void { void ctx.sessions.refreshProjections(parentSessionId) }, }) + const footerAvailable = { + getSnapshot: () => ctx.slots.entries('conversation.session.header.actions.subagent.footer').length > 0, + subscribe: (listener: () => void) => ctx.slots.subscribe('conversation.session.header.actions.subagent.footer', listener), + } ctx.slots.inject( 'conversation.session.header.lineage', () => ctx.slots.register({ @@ -84,11 +83,13 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // Between the preset label (-10) and background tasks (20), after Team - // navigation (-20). + // Between the preset label (-10) and background tasks (20). order: 10, locale: NS, - inject: catalogActions, + inject: (sessionId): SubagentCatalogActionInjected => ({ + ...catalogActions(sessionId), hooks: { footerAvailable }, + }), + children: { 'conversation.session.header.actions.subagent.footer': { kind: 'list', scope: 'session' } }, }, SubagentCatalogAction), ) ctx.slots.inject( diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 897b65ece0..27a5b61391 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -5,6 +5,12 @@ export const NS = 'subagent' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'section.title': '子智能体', + 'section.empty': '暂无子智能体', + 'progress.completed': '{count} 已完成', + 'progress.active': '{count} 进行中', + 'progress.inactive': '{count} 未运行', + 'progress.external': '{count} 外部任务', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'duration.hours': '{hours}小时{minutes}分{seconds}秒', @@ -46,11 +52,16 @@ export const zh = { 'readonly.oneShot.title': '一次性子智能体记录', 'readonly.title': '此子智能体暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', - 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', } as const /** English dictionary, key-identical to the Chinese source of truth. */ export const en: Record = { + 'section.title': 'Subagents', + 'section.empty': 'No subagents', + 'progress.completed': '{count} completed', + 'progress.active': '{count} in progress', + 'progress.inactive': '{count} not running', + 'progress.external': '{count} external', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -92,7 +103,6 @@ export const en: Record = { 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', - 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', } /** Key domain of the `subagent` namespace (zh is the source of truth). */ @@ -100,6 +110,12 @@ export type SubagentKey = keyof typeof zh /** Japanese copy, checked against the English keys. */ export const ja = { + 'section.title': 'サブエージェント', + 'section.empty': 'サブエージェントはありません', + 'progress.completed': '{count}件完了', + 'progress.active': '{count}件進行中', + 'progress.inactive': '{count}件停止中', + 'progress.external': '{count}件外部タスク', 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'duration.hours': '{hours}時間{minutes}分{seconds}秒', @@ -141,11 +157,16 @@ export const ja = { 'readonly.oneShot.title': '単発サブエージェントの記録', 'readonly.title': 'このサブエージェントは現在読み取り専用です', 'readonly.oneShot.body': '単発タスクは追加のメッセージを受け付けません。ここで実行記録の全体を確認できます。', - 'readonly.body': '親セッションがオフラインです。再度開いてメッセージの送信を続けてください。', } satisfies Record /** Spanish dictionary, checked against the English keys. */ export const es = { + 'section.title': 'Subagentes', + 'section.empty': 'No hay subagentes', + 'progress.completed': '{count} completado', + 'progress.active': '{count} en progreso', + 'progress.inactive': '{count} sin ejecutar', + 'progress.external': '{count} externos', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -187,11 +208,16 @@ export const es = { 'readonly.oneShot.title': 'Registro de subagente de una sola vez', 'readonly.title': 'Este subagente es de solo lectura por ahora', 'readonly.oneShot.body': 'Las tareas únicas no aceptan seguimientos; revise el registro de ejecución completo aquí.', - 'readonly.body': 'La sesión para padres está fuera de línea; Vuelva a abrirlo para continuar enviando mensajes.', } satisfies Record /** French dictionary, checked against the English keys. */ export const fr = { + 'section.title': 'Sous-agents', + 'section.empty': 'Aucun sous-agent', + 'progress.completed': '{count} terminé', + 'progress.active': '{count} en cours', + 'progress.inactive': '{count} à l’arrêt', + 'progress.external': '{count} externes', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -233,11 +259,16 @@ export const fr = { 'readonly.oneShot.title': 'Enregistrement de sous-agent unique', 'readonly.title': 'Ce sous-agent est actuellement en lecture seule', 'readonly.oneShot.body': 'Les tâches ponctuelles n’acceptent pas de messages de suivi ; consultez leur journal d’exécution complet ici.', - 'readonly.body': 'La session parente est hors ligne ; rouvrez-la pour continuer à envoyer des messages.', } satisfies Record /** German dictionary, checked against the English keys. */ export const de = { + 'section.title': 'Subagenten', + 'section.empty': 'Keine Subagenten', + 'progress.completed': '{count} abgeschlossen', + 'progress.active': '{count} in Bearbeitung', + 'progress.inactive': '{count} nicht aktiv', + 'progress.external': '{count} extern', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -279,11 +310,16 @@ export const de = { 'readonly.oneShot.title': 'Einmaliger Subagentendatensatz', 'readonly.title': 'Dieser Subagent ist derzeit schreibgeschützt', 'readonly.oneShot.body': 'Einmalige Aufgaben akzeptieren keine Folgeaufgaben; Sehen Sie sich hier den vollständigen Ausführungsbericht an.', - 'readonly.body': 'Die übergeordnete Sitzung ist offline. Öffnen Sie es erneut, um weiterhin Nachrichten zu senden.', } satisfies Record /** Russian dictionary, checked against the English keys. */ export const ru = { + 'section.title': 'Субагенты', + 'section.empty': 'Нет субагентов', + 'progress.completed': '{count} завершено', + 'progress.active': '{count} в процессе', + 'progress.inactive': '{count} не выполняются', + 'progress.external': '{count} внешних', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -325,11 +361,16 @@ export const ru = { 'readonly.oneShot.title': 'Разовая запись субагента', 'readonly.title': 'Этот субагент пока доступен только для чтения.', 'readonly.oneShot.body': 'Одноразовые задачи не допускают последующих действий; просмотреть полную запись выполнения можно здесь.', - 'readonly.body': 'Родительский сеанс находится в автономном режиме; откройте его повторно, чтобы продолжить отправку сообщений.', } satisfies Record /** Portuguese dictionary, checked against the English keys. */ export const pt = { + 'section.title': 'Subagentes', + 'section.empty': 'Nenhum subagente', + 'progress.completed': '{count} concluído', + 'progress.active': '{count} em andamento', + 'progress.inactive': '{count} sem execução', + 'progress.external': '{count} externos', 'duration.seconds': '{seconds}s', 'duration.minutes': '{minutes}m {seconds}s', 'duration.hours': '{hours}h {minutes}m {seconds}s', @@ -371,5 +412,4 @@ export const pt = { 'readonly.oneShot.title': 'Registro de subagente único', 'readonly.title': 'Este subagente é somente leitura por enquanto', 'readonly.oneShot.body': 'Tarefas únicas não aceitam acompanhamentos; revise o registro completo da execução aqui.', - 'readonly.body': 'A sessão pai está offline; reabra-o para continuar enviando mensagens.', } satisfies Record diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index ddc5bed7f9..6d9c3fcbb5 100644 --- a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -136,10 +136,7 @@ export function ConversationSlotPanel({ || session.running const shellPhase = active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' const summaryBlank = useSessions(state => state.byId[sessionId]?.blank) - const parentAvailabilityPending = session.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined const settling = (shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true) const phase = settling ? 'settling' : hero ? 'hero' : 'active' return renderFactorySlot('conversation.content', { variant: 'embedded', phase, hero }, { diff --git a/packages/client/ui-subagent/src/client/slots.ts b/packages/client/ui-subagent/src/client/slots.ts new file mode 100644 index 0000000000..a3e379e62f --- /dev/null +++ b/packages/client/ui-subagent/src/client/slots.ts @@ -0,0 +1,13 @@ +/** Extension position below the root conversation's subagent catalog. */ + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Content below the root Session's subagent tree. */ + 'conversation.session.header.actions.subagent.footer': { + kind: 'list' + scope: 'session' + } + } +} + +export {} diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index d75c9d8639..b5006a277b 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { SessionListState, SessionSnapshot, SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -12,7 +12,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { - SubagentHeaderLineage, type SubagentCatalogInjected, + SubagentHeaderLineage, type SubagentCatalogInjected, type SubagentCatalogActionInjected, } from '../src/client/SubagentHeaderLineage.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, @@ -108,6 +108,19 @@ describe('apply', () => { expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight']) }) + it('keeps the root catalog extension available when optional Sidebar services arrive', async () => { + const { ctx } = await fullBench(FAMILY) + onTestFinished(() => ctx.fiber.dispose()) + ctx.slots.register({ name: 'conversation.session.header.actions.subagent.footer', id: 'test-content' }, () => null) + const registerResource = vi.fn(() => () => {}) + const registerTab = vi.fn(() => () => {}) + ctx.provide('resources', { register: registerResource } as never) + ctx.provide('sidebarRightTabs', { register: registerTab } as never) + await vi.waitFor(() => { expect(registerResource).toHaveBeenCalledOnce() }) + expect(registerTab).toHaveBeenCalledOnce() + expect(ctx.slots.entries('conversation.session.header.actions.subagent.footer')).toHaveLength(1) + }) + it('registers catalog actions and selects read-only subagent composers from session facts', async () => { const { ctx, face } = await fullBench(FAMILY) const catalogEntry = ctx.slots.entries('conversation.session.header.lineage') @@ -139,7 +152,20 @@ describe('apply', () => { const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! expect(actionEntry.options.order).toBe(10) - expect(actionEntry.inject).toBe(catalogEntry.inject) + const actionProps = (actionEntry.inject as (id: SessionId) => SubagentCatalogActionInjected)(sid('parent')) + const source = actionProps.hooks.footerAvailable + const changed = vi.fn() + const unsubscribe = source.subscribe(changed) + expect(source.getSnapshot()).toBe(false) + const remove = ctx.slots.register({ + name: 'conversation.session.header.actions.subagent.footer', id: 'test-footer', + }, () => null) + expect(source.getSnapshot()).toBe(true) + await vi.waitFor(() => { expect(changed).toHaveBeenCalledOnce() }) + remove() + expect(source.getSnapshot()).toBe(false) + await vi.waitFor(() => { expect(changed).toHaveBeenCalledTimes(2) }) + unsubscribe() const composerEntry = ctx.slots.entries('conversation.composer') .find(entry => entry.component === SubagentReadOnlyComposer)! @@ -156,19 +182,14 @@ describe('apply', () => { }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() - expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }))) + expect(select(owner({ address: { ...address, mode: 'one-shot' } }))) .toEqual({ reason: 'one-shot' }) // One-shot stays read-only even while running: it has no stop action. - expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) + expect(select(owner({ address: { ...address, mode: 'one-shot' } }, true))) .toEqual({ reason: 'one-shot' }) - expect(select(owner({ address: { ...address, mode: 'unknown' }, parentAvailable: true }))) + expect(select(owner({ address: { ...address, mode: 'unknown' } }))) .toEqual({ reason: 'unknown' }) expect(select(owner({ address }))).toBeNull() - expect(select(owner({ address, parentAvailable: true }))).toBeNull() - expect(select(owner({ address, parentAvailable: false }))) - .toEqual({ reason: 'parent-unavailable' }) - // A RUNNING parent-offline continuable yields the default composer, whose - // disabled input still carries the primary Stop; stopped, it takes back over. - expect(select(owner({ address, parentAvailable: false }, true))).toBeNull() + expect(select(owner({ address }, true))).toBeNull() }) }) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 3ac3a7b325..030730f6e7 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -7,6 +7,7 @@ import type { SessionListState, SessionSummary, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' import type { SubagentAddress, SubagentCatalogEntry } from '@deepseek-ai/dsh-subagent/client' +import type { TeamProjection, TeamTaskId, TeamTaskView } from '@deepseek-ai/dsh-agent-team/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { @@ -20,6 +21,7 @@ afterEach(() => { cleanup() vi.useRealTimers() vi.restoreAllMocks() + vi.unstubAllGlobals() }) const PARENT = 'parent' as SessionId @@ -27,11 +29,25 @@ const CHILD = 'child' as SessionId const GRANDCHILD = 'grandchild' as SessionId const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh) +type HeaderCatalogProps = SubagentHeaderLineageProps + & Pick + type CatalogFixtureEntry = Entry extends SubagentCatalogEntry ? Omit & { activity: 'running' | 'inactive' } : never -type CatalogFixture = { entries: readonly CatalogFixtureEntry[]; parentAvailable: boolean; state: SessionListState['projectionsBySession'][SessionId]['state']; error: SessionListState['projectionsBySession'][SessionId]['error'] } +type CatalogFixture = { + team?: TeamProjection + entries: readonly CatalogFixtureEntry[] + state: SessionListState['projectionsBySession'][SessionId]['state'] + error: SessionListState['projectionsBySession'][SessionId]['error'] +} + +const sharedTask: TeamTaskView = { + id: 'shared-task' as TeamTaskId, revision: 1, subject: 'Plan shared work', description: 'Prepare the implementation', + status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], +} +const sharedTeam: TeamProjection = { enabled: true, tasks: [sharedTask] } function catalog(over: Partial = {}): CatalogFixture { return { @@ -45,7 +61,6 @@ function catalog(over: Partial = {}): CatalogFixture { label: 'reviewer', activity: 'inactive', }, ], - parentAvailable: true, state: 'ready', error: null, ...over, @@ -79,7 +94,10 @@ function props( phase: 'ready', projectionsBySession: Object.fromEntries(Object.entries(catalogs).map(([id, catalog]) => [id, { state: catalog.state, error: catalog.error, - values: { subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })) }, + values: { + ...catalog.team === undefined ? {} : { agentTeam: catalog.team }, + subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })), + }, }])) , } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { @@ -118,15 +136,17 @@ function props( ...standard, sessionId: PARENT, useSessions, + useFooterAvailable: (select: (available: boolean) => T): T => select(true), useSessionStatus: (select: (snapshot: SessionStatusSnapshot) => T): T => select(statuses), useSession: (select: (snapshot: SessionSnapshot) => T): T => select(snapshot), openChild: vi.fn(), openChildAside: vi.fn(), refreshProjection: vi.fn(), + renderSlot: vi.fn(() => null), lineageSessionId: PARENT, displayTitle: 'Parent title', t, - } satisfies SubagentHeaderLineageProps + } satisfies HeaderCatalogProps } function summary(id: SessionId, updatedAt: number): SessionSummary { @@ -146,14 +166,34 @@ function hoverCatalog(trigger: HTMLElement): void { act(() => { vi.advanceTimersByTime(150) }) } +function enableTooltips(): void { + class TooltipObserver implements ResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element): void { + const size = { inlineSize: 160, blockSize: 30 } + this.callback([{ + target, borderBoxSize: [size], contentBoxSize: [size], devicePixelContentBoxSize: [size], contentRect: new DOMRect(), + }], this) + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', TooltipObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) +} + /** * Both header seats as the real header composes them: the breadcrumb switcher * (child sessions, lineage slot) and the actions-band catalog entry (root * sessions) — exactly one renders per form. The actions seat sees the same * session the lineage renders. */ -function HeaderCatalog(props: SubagentHeaderLineageProps) { - const actionProps: SubagentCatalogActionProps = { ...props, sessionId: props.lineageSessionId } +function HeaderCatalog(props: HeaderCatalogProps) { + const actionProps: SubagentCatalogActionProps = { + ...props, sessionId: props.lineageSessionId, + useProjection: ((_key: 'agentTeam', selector: (value: TeamProjection | undefined) => boolean) => + props.useSessions(state => selector(state.projectionsBySession[props.lineageSessionId]?.values.agentTeam))) as SubagentCatalogActionProps['useProjection'], + } return ( <> @@ -163,6 +203,211 @@ function HeaderCatalog(props: SubagentHeaderLineageProps) { } describe('SubagentHeaderLineage', () => { + it.each([ + { dictionary: zh, total: '2 个子智能体', running: '1 个子智能体,正在运行' }, + { dictionary: en, total: '2 subagents', running: '1 subagent running' }, + ])('keeps the total accessible name separate from the running description: $total', ({ dictionary, total, running }) => { + const input = props(catalog()) + const view = render() + const trigger = screen.getByRole('button', { name: total }) + expect(trigger.textContent).toBe(total) + expect(document.getElementById(trigger.getAttribute('aria-describedby')!)?.textContent).toBe(running) + view.rerender() + expect(screen.getByRole('button', { name: total }).getAttribute('aria-describedby')).toBeNull() + }) + + it('shows a non-collapsible heading with direct-member progress beside shared tasks', () => { + const finished = 'finished' as SessionId + const paused = 'paused' as SessionId + const external = 'external' as SessionId + const entries: CatalogFixture['entries'] = [ + { id: CHILD, mode: 'continuable', label: 'worker', activity: 'running' }, + { id: finished, mode: 'continuable', label: 'finished', activity: 'inactive' }, + { id: paused, mode: 'continuable', label: 'paused', activity: 'inactive' }, + { id: external, mode: 'external', label: 'external', activity: 'running' }, + ] + const summaries: Record = { + [CHILD]: { ...summary(CHILD, 1), running: true, projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } } }, + [finished]: { ...summary(finished, 1), projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } } }, + [paused]: { ...summary(paused, 1), projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: false } } }, + [external]: { + ...summary(external, 1), running: true, projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } }, + }, + } + const input = props(catalog({ entries, team: sharedTeam }), { + [CHILD]: catalog({ entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'nested', activity: 'running' }] }), + }, summaries) + const view = render() + fireEvent.click(screen.getByRole('button', { name: '4 个子智能体' })) + const header = view.container.ownerDocument.querySelector('[data-subagent-section-header]')! + expect(within(header).getByRole('heading', { name: '子智能体' })).toBeTruthy() + expect(within(header).queryByRole('button')).toBeNull() + expect(header.textContent).toBe('子智能体1 已完成\u2002·\u20021 进行中\u2002·\u20021 未运行\u2002·\u20021 外部任务') + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + expect(header.textContent).toContain('1 进行中') + expect(header.textContent).not.toContain('2 进行中') + view.rerender() + expect(header.textContent).toBe('Subagents1 completed\u2002·\u20021 in progress\u2002·\u20021 not running\u2002·\u20021 external') + }) + + it('omits zero progress counts from a completed-only catalog header', () => { + const input = props(catalog({ + team: sharedTeam, entries: [{ id: CHILD, mode: 'continuable', label: 'finished', activity: 'inactive' }], + }), {}, { + [CHILD]: { ...summary(CHILD, 1), projectionValues: { subagentTiming: { settledMs: 0, lastTurnCompleted: true } } }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '1 个子智能体' })) + const header = document.querySelector('[data-subagent-section-header]')! + expect(header.textContent).toBe('子智能体1 已完成') + }) + + it.each([ + { name: 'absent Team projection', team: undefined, footer: true }, + { name: 'disabled Team mode', team: { ...sharedTeam, enabled: false }, footer: true }, + { name: 'empty tasks', team: { enabled: true, tasks: [] }, footer: true }, + { name: 'deleted-only tasks', team: { enabled: true, tasks: [{ ...sharedTask, status: 'deleted' }] }, footer: true }, + { name: 'failure without tasks', team: { enabled: true, tasks: [], failure: 'Invalid task record' }, footer: true }, + { name: 'unavailable task footer', team: sharedTeam, footer: false }, + ] satisfies { name: string; team: TeamProjection | undefined; footer: boolean }[])( + 'omits section headings from a members-only catalog with $name', ({ team, footer }) => { + const input = props(catalog({ ...team === undefined ? {} : { team } })) + render( select(footer)} />) + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + expect(screen.getByRole('tree')).toBeTruthy() + expect(screen.queryByRole('heading')).toBeNull() + expect(document.querySelector('[data-subagent-section-header]')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + }, + ) + + it('shows the description above the Session id and opens the row without a description toggle', () => { + const description = 'Check the construction and all bounds. '.repeat(20).trim() + const input = props(catalog({ + entries: [{ id: CHILD, mode: 'continuable', label: description, activity: 'running' }], + })) + render() + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + const primary = within(row).getByText(description) + const id = within(row).getByText(CHILD) + expect(primary.compareDocumentPosition(id) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(row.getAttribute('aria-label')).toMatch(/^Check the construction/) + expect(primary.tagName).toBe('SPAN') + expect(primary.getAttribute('aria-expanded')).toBeNull() + expect(primary.querySelector('svg')).toBeNull() + expect(within(row).queryByRole('button', { name: /描述/ })).toBeNull() + fireEvent.click(primary) + expect(input.openChild).toHaveBeenCalledExactlyOnceWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }) + expect(screen.queryByRole('treeitem')).toBeNull() + }) + + it.each([undefined, ''])('keeps a single ID line when the creation description is %s', (label) => { + const input = props(catalog({ + entries: [{ id: CHILD, mode: 'one-shot', ...label === undefined ? {} : { label }, activity: 'running' }], + }), {}, { [CHILD]: { ...summary(CHILD, 1), running: true } }) + render() + fireEvent.click(screen.getByRole('button', { name: /\d+ 个子智能体$/ })) + const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) + expect(within(row).getAllByText(CHILD)).toHaveLength(1) + expect(row.getAttribute('aria-label')).toMatch(new RegExp(`^${CHILD}`)) + }) + + it('hides an empty Team entry but keeps shared tasks and errors reachable without children', () => { + const input = props(catalog({ entries: [] })) + const initial = input.useSessions(state => state) + const mode: { enabled: boolean; tasks: TeamTaskView[]; failure?: string } = { enabled: true, tasks: [] } + const values = { ...initial.projectionsBySession[PARENT]!.values, agentTeam: mode } + const current = { + ...input, + useSessions: (select: (snapshot: SessionListState) => T): T => select({ + ...initial, projectionsBySession: { [PARENT]: { state: 'ready', error: null, values } }, + }), + renderSlot: () => mode.tasks.some(task => task.status !== 'deleted') + ?
    Tasks
    + : mode.failure === undefined ? null :
    {mode.failure}
    , + } + const view = render() + expect(screen.queryByRole('button')).toBeNull() + mode.tasks = [{ + id: 'task-1' as TeamTaskId, revision: 1, subject: 'Plan work', description: 'Prepare tasks before spawning workers', + status: 'pending', blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], + }] + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '0 个子智能体' })) + const tree = screen.getByRole('tree') + const board = screen.getByRole('region', { name: 'Shared tasks' }) + expect(tree.compareDocumentPosition(board) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.getByRole('heading', { name: zh['section.title'] })).toBeTruthy() + expect(screen.getByText(zh['section.empty'])).toBeTruthy() + expect(screen.getAllByRole('dialog')).toHaveLength(1) + mode.tasks = [] + view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + mode.tasks = [{ ...sharedTask, status: 'deleted' }] + view.rerender() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + mode.tasks = [] + mode.failure = 'Invalid task record' + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '0 个子智能体' })) + expect(screen.getByRole('dialog')).toBeTruthy() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + expect(document.querySelector('[data-subagent-section-header]')).toBeNull() + view.rerender( select(false)} />) + expect(screen.queryByRole('button')).toBeNull() + mode.enabled = false + view.rerender() + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + }) + + it.each([false, true])('keeps child navigation without reading or rendering a task board (listed: %s)', (listed) => { + const input = props(catalog({ team: sharedTeam }), {}, listed ? { + [CHILD]: { ...summary(CHILD, 1), origin: 'subagent', parentId: PARENT }, + } : {}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }) + const readProjection = vi.fn((): never => { throw new Error('child task-board projection read') }) + const readFooter = vi.fn((): never => { throw new Error('child footer subscription') }) + const renderFooter = vi.fn(() =>
    Tasks
    ) + render(<> + + + ) + fireEvent.click(screen.getByRole('button', { name: /切换子智能体/u })) + expect(screen.getByRole('tree')).toBeTruthy() + expect(screen.queryByRole('region', { name: 'Shared tasks' })).toBeNull() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.queryByText(zh['section.empty'])).toBeNull() + expect(document.querySelector('[data-subagent-section-header]')).toBeNull() + expect(readProjection).not.toHaveBeenCalled() + expect(readFooter).not.toHaveBeenCalled() + expect(renderFooter).not.toHaveBeenCalled() + }) + + it('leaves task-board keys alone and dismisses the shared panel from outside focus', () => { + const input = props(catalog()) + render(<>