diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml similarity index 68% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 5d89e6b732..fc866f6f10 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 47f2e28c2aac583ce4ca810157e9c7abe58256d5 -2026-08-03-per-session-agent-presets.zh.md: 88b643ea710364f18ff7c73a8ca0e10de0fbb44c +2026-08-03-per-session-agent-presets.md: a1f2ad294edfdaaf2dab2d02e858e4dcb0a0c746 +2026-08-03-per-session-agent-presets.zh.md: b745c22c91b4017131a32796c085a869a2eabaf0 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md index 47f2e28c2a..a1f2ad294e 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.md @@ -1,6 +1,7 @@ # Agent Note: A session's agent is composed from a preset cordis.yml Status: implemented +Archived: 2026-09-18 English | [中文](2026-08-03-per-session-agent-presets.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md rename to .agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md index 88b643ea71..b745c22c91 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/archived/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -1,6 +1,7 @@ # Agent Note: 会话的 agent 由一份 preset cordis.yml 组装而成 Status: implemented +Archived: 2026-09-18 [English](2026-08-03-per-session-agent-presets.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index ca509c1070..6fc82fe8a6 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -160,6 +160,9 @@ "architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml": "sha256:43c9128079955e974fe1da3ce1a10779b4f5a25ac3629f92e3b1de847ad45050", "architecture/2026-08-01-packaged-ripgrep-search.md": "sha256:36cf2d6daa654abced690965338aec4628069dc77be3a1da6368a9b097a59ff4", "architecture/2026-08-01-packaged-ripgrep-search.zh.md": "sha256:45b2fe8e0d492eeff313d2ef3c98fd69761b3257eee3476b8bf53312e8b6095f", + "architecture/2026-08-03-per-session-agent-presets.i18n.yaml": "sha256:3fe057a67e41f9ce4f5cc8b162e264494b39169588fe9c9e48dcf95fd1b208a1", + "architecture/2026-08-03-per-session-agent-presets.md": "sha256:d4c6c6af11f41b57e95ca359e2cbe09780cb0851037998824a5966cab5e12626", + "architecture/2026-08-03-per-session-agent-presets.zh.md": "sha256:c91248b2d9cc5c51c77e9d195aca5d51a06684971b1afafc60b2def72248afca", "architecture/2026-08-03-pi-ai-declared-provider-catalog.i18n.yaml": "sha256:c1a59c3c04afada633740c808b98e765ccad09220c4c79d8f4207313c9e9aab6", "architecture/2026-08-03-pi-ai-declared-provider-catalog.md": "sha256:fae0c59ece887f7d9344b6254d20cc671894074d53eefffb85dbf45b9104d2b6", "architecture/2026-08-03-pi-ai-declared-provider-catalog.zh.md": "sha256:8032cefedd51b292d810051ff32e8825958e6d90b2e523a374294ef2f859ca1e", @@ -1780,6 +1783,9 @@ "simplification/2026-08-06-user-bubbles-drop-the-branch-action.i18n.yaml": "sha256:e000606d801984d4ee473779891ffe8c197f64f54b75efd82fb46dc84217cd59", "simplification/2026-08-06-user-bubbles-drop-the-branch-action.md": "sha256:b93ce0d79ee72306d569d80fd81308a2b5704d95df8854b7cbd2f3d898b3f776", "simplification/2026-08-06-user-bubbles-drop-the-branch-action.zh.md": "sha256:999e3e1252f771c420506a80b7c0d512b70da6770e3e62bb92169180b4cee3e8", + "simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml": "sha256:43bba45597b198a94e3cbf8df31794239c521ecf002338636dca2c099acd94e1", + "simplification/2026-08-08-copy-only-preset-authoring.md": "sha256:be77d385a3c4db534fbbd5b4a830fcb7f6bcd8401221617859569e2c4d837069", + "simplification/2026-08-08-copy-only-preset-authoring.zh.md": "sha256:4c544a08e741c4a273a89dc2a3fda44dffb46af15d0feb8f7bf8efa816bfbd13", "simplification/2026-08-08-remove-cli-demo.i18n.yaml": "sha256:1baa8649e186e159afb03f112059f46f7a35c303a6039374db8bc4ca743d4bdf", "simplification/2026-08-08-remove-cli-demo.md": "sha256:a34ce79b674c67a6a47d99c1fa7141b2ae58e36b99da5989d323d229d1921baa", "simplification/2026-08-08-remove-cli-demo.zh.md": "sha256:96a2a7cc34c7b0abf71b77d666a6349d681fd8a0d9a9c81e30d2aa538d62797a", diff --git a/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml new file mode 100644 index 0000000000..0af0acb43a --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.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-08-08-copy-only-preset-authoring.md +2026-08-08-copy-only-preset-authoring.md: 07f8a6553cf9c70d5289a074b6e6d0d677dd4a28 +2026-08-08-copy-only-preset-authoring.zh.md: 58757a9774354b116d235f890beb136034f29904 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md rename to .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md index 13d0bfc7c4..07f8a6553c 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.md @@ -1,6 +1,7 @@ # Agent Note: Copy-only preset authoring, and the way into a preset's files Status: implemented +Archived: 2026-09-20 English | [中文](2026-08-08-copy-only-preset-authoring.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md rename to .agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 2b93ac1b81..58757a9774 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/archived/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -1,6 +1,7 @@ # Agent Note: 仅复制的 preset 创作,与通往 preset 文件的入口 Status: implemented +Archived: 2026-09-20 [English](2026-08-08-copy-only-preset-authoring.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 6a0f5d94c7..e09f7db568 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 7260d11c84f8fb07cf6c4691357f92e0d00fd8f3 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 653fb3d9b782d9010f693e4af8ad94475abb9d94 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index ff00327c38..7260d11c84 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -40,7 +40,7 @@ The Python client supplies an explicit Harness home and selects the `sdk` profil Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/bundle/web-app/cordis.patch.yml*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 3f7f18a990..653fb3d9b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -40,7 +40,7 @@ Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 pa exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/bundle/web-app/cordis.patch.yml*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml index 79ed44e6a0..4cc71b035b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md -2026-08-10-host-plane-ownership-after-presets.md: 78410368cdc66b14fe478a4de9f0ae7a1cbefac5 -2026-08-10-host-plane-ownership-after-presets.zh.md: 862b793fab23b209f6f43b2e8045ad7330c30ba0 +2026-08-10-host-plane-ownership-after-presets.md: daf48f27c2228b71319d6f5410e6cabe3bc02b39 +2026-08-10-host-plane-ownership-after-presets.zh.md: 56babf5e715584b4ee061a334158d46e59b0d350 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md index 78410368cd..daf48f27c2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md) ## Problem -[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). +[Per-session agent presets](2026-09-18-declarative-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). Two more readers were still on the wrong side of that line. @@ -18,15 +18,15 @@ Nothing named an agent that joined no preset. The join is a scope-parent link; w **The meter is host-plane.** `dsh-token-meter` returns to the host composition and leaves the presets' `isolate` map, so `compaction-basic` and `tool-result-pruner` resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion `tasks` and `goals` are already read by, applied to a Service whose *projection* reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process. -**An unjoined agent is named twice, at two different points.** `AgentPresets` logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at `system-prompt/assemble`, not at publication, because an unjoined agent is legal until it addresses a model: `recompose` binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range. +**An unjoined agent fails at model use.** The invariant companion checks `system-prompt/assemble`, because a bare Agent can legally be bound later through `recompose`. Host-only and cold-scope prompt reads have no Agent and remain outside this check. -Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal ([`dsh-session-projection`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.md)). +Projection key presence is not a per-session capability signal ([session projection](../../../../packages/session/session-projection/README.md)). Temporary plugins mounted through `cordis_mount` belong to the composition rather than the invoking session ([tool Cordis](../../../../packages/extensions/tool-cordis/README.md)). [Declarative presets](2026-09-18-declarative-agent-presets.md) own revision retention and reclaim retired trees after their final reference releases. ## Testing -`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. +`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any Agent is created — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. -`packages/preset/agent-presets/tests/mount.spec.ts` asserts the warning fires exactly once for a bare agent and not at all for a joined one. `tests/invariant.spec.ts` carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass. +`packages/preset/agent-preset-registry/tests/invariant.spec.ts` rejects an unjoined Agent's assembly and accepts a joined Agent, a Host read and a cold-scope read. ## Alternatives considered @@ -42,4 +42,4 @@ Three limits stay open and are recorded where they bite rather than fixed here: The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. -The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load `dsh-invariants`, which fences package tests and development hosts, not a shipped one. +The invariant reaches compositions loading `dsh-invariants`. Production entry points must compose Agents explicitly; installing the registry alone does not bind them. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md index 862b793fab..56babf5e71 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[逐会话 agent preset](2026-08-03-per-session-agent-presets.zh.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md))。 +[逐会话 agent preset](2026-09-18-declarative-agent-presets.zh.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md))。 还有两个读取点仍站在这条线的错误一侧。 @@ -18,15 +18,15 @@ Status: implemented **meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compaction-basic` 与 `tool-result-pruner` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks` 与 `goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。 -**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。 +**未加入的 Agent 在使用模型时失败。** 不变量 companion 检查 `system-prompt/assemble`,因为未绑定的 Agent 可以合法地稍后通过 `recompose` 绑定。纯 Host 和历史 scope 提示词读取没有 Agent,不属于此检查范围。 -有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.zh.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.zh.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/extensions/tool-cordis/README.zh.md))。 +投影 key 是否存在不能代表逐会话能力([会话投影](../../../../packages/session/session-projection/README.zh.md))。通过 `cordis_mount` 挂载的临时插件属于组装,而非调用会话([Cordis 工具](../../../../packages/extensions/tool-cordis/README.zh.md))。[声明式 preset](2026-09-18-declarative-agent-presets.zh.md) 负责代际保留,在最后一个引用释放后回收退役树。 ## 测试 -`apps/cli/tests/web-agent-presets.e2e.ts` 在本文件中任何 preset 挂载**之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 +`apps/cli/tests/web-agent-presets.e2e.ts` 在创建任何 Agent **之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 -`packages/preset/agent-presets/tests/mount.spec.ts` 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。`tests/invariant.spec.ts` 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。 +`packages/preset/agent-preset-registry/tests/invariant.spec.ts` 拒绝未加入的 Agent 组装,并接受已加入的 Agent、Host 读取和历史 scope 读取。 ## 考虑过的替代方案 @@ -42,4 +42,4 @@ Status: implemented context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 -那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。 +不变量覆盖加载了 `dsh-invariants` 的组装。生产入口必须显式组装 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 b44324b5dd..bf6ed1340c 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md -2026-09-09-consumer-owned-startup-strictness.md: 2f16078c7051b4038c1d48120b500f0fcd465aef -2026-09-09-consumer-owned-startup-strictness.zh.md: c0fe61e5f708f79f6a5b903f3157ee358c3086bc +2026-09-09-consumer-owned-startup-strictness.md: bbe975e13b251e2e0d18c4e002efbf1d362548f3 +2026-09-09-consumer-owned-startup-strictness.zh.md: 4bd2bfe026a13ccbd46215d2c0165b5a8c5327f7 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 2f16078c70..bbe975e13b 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 @@ -18,7 +18,7 @@ The audit treats a throwing `disabled` expression as an entry failure, not a dis The audit runs only during initial application boot. Later config HMR remains best effort and keeps the failed candidate visible for repair. -This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.md), including its [client plugin roster](2026-07-23-client-plugin-loading-model.md). [Per-session presets](2026-08-03-per-session-agent-presets.md) own a separate strict subtree audit. +This policy governs [Web host boot](2026-07-24-web-config-tree-boot-and-transport-layering.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. ## Alternatives considered 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 c0fe61e5f7..4bd2bfe026 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 @@ -18,7 +18,7 @@ Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headles 该审计只在应用首次启动时运行。之后的 config HMR 仍采用 best effort,并保留 failed candidate 供后续修复。 -该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-08-03-per-session-agent-presets.zh.md)持有独立的严格子树审计。 +该策略适用于 [Web host 启动](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md),包括其 [client 插件名册](2026-07-23-client-plugin-loading-model.zh.md)。[按会话的 preset](2026-09-18-declarative-agent-presets.zh.md)持有独立的严格子树审计。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml similarity index 56% rename from .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml index 505c69b9b3..8c41c47eeb 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: 13d0bfc7c49d4b97b6c444fe1ff6c6455fef53ec -2026-08-08-copy-only-preset-authoring.zh.md: 2b93ac1b81ddb2461308baffb3b6563fe240f9d5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md +2026-09-18-declarative-agent-presets.md: 55ec173b33b7ddd62c88c68afc8b7546074aae0a +2026-09-18-declarative-agent-presets.zh.md: 60c81de4d981818f805b1014c2e3f05f16d1e8a9 diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md new file mode 100644 index 0000000000..55ec173b33 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md @@ -0,0 +1,39 @@ +# Agent Note: Declarative Agent presets and retained revisions + +Status: implemented + +English | [中文](2026-09-18-declarative-agent-presets.zh.md) + +## Problem + +Preset directories duplicate Cordis configuration ownership. Separate discovery, metadata, copying and editing APIs cannot express the same composition through an ordinary profile patch. Disposing a definition during a save must also preserve plugins still used by running Agents. + +## Decision + +`dsh-agent-preset-registry` owns selection and runtime revisions; `dsh-agent-preset` declares an identity, display metadata and child plugin list in ordinary Cordis YAML. Definitions activate eagerly. Activation failures remain in the roster and reject new bindings without preventing the application from starting. + +The registry owns each revision's scope and Loader tree. Agents link their own scopes to the selected revision, and children inherit their parent's exact revision. Updates retire the former revision; Agents and temporary cold readers retain references until disposal. The final release disposes a retired tree. Host registries and the Agent loop remain shared; preset service providers and consumers require an isolated realm. + +The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host. + +Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The existing chooser policy and blank-session selection remain: hiding the chooser uses the deployment default without deleting the saved user preference. + +## Alternatives considered + +**Keep directory presets alongside declarations.** Two writable sources would compete for an identity and require precedence, migration and editing rules. Ordinary profile configuration supplies the required persistence and layering, so presets have no separate paths. + +**Let each declaring plugin own its live child tree.** Loader disposal during an edit would revoke tools from running Agents. Registry ownership gives retained revisions an explicit lifetime independent of the declaration. + +**Load only on first selection.** Lazy activation delays diagnostics and adds pending-first-use state. Eager activation is acceptable for these compositions and exposes failures before selection. + +**Reject application startup for a broken preset.** A failed optional capability set should remain repairable in Web. Healthy definitions and Host services remain available. + +## Consequences + +One process can run Agents with different capabilities while sharing each selected revision. Retired revisions remain in memory while referenced. Presets do not provide a security sandbox, and a user override replaces the entire child list rather than merging future builtin changes. + +The former directory and per-session-mount decision is preserved as [historical context](../../archived/architecture/2026-08-03-per-session-agent-presets.md). Host service ownership remains documented in [the Host-plane note](2026-08-10-host-plane-ownership-after-presets.md). + +## Testing + +Registry tests cover retained parent and child scopes, cold-reader leases, selection logging and failed activation. Editor tests cross real profile patches, check conflict rejection and preserve nested expressions. The shipped Web composition test checks activation, scoped tools, Creator skills and profile edits against built packages. diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md new file mode 100644 index 0000000000..60c81de4d9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 声明式 Agent preset 与保留代际 + +Status: implemented + +[English](2026-09-18-declarative-agent-presets.md) | 中文 + +## Problem + +Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数据、复制和编辑 API 无法通过普通 profile 补丁表达相同组装。保存时释放定义,也必须保留运行中 Agent 仍在使用的插件。 + +## Decision + +`dsh-agent-preset-registry` 负责选择与运行时代际;`dsh-agent-preset` 在普通 Cordis YAML 中声明标识、显示元数据和子插件列表。定义提前激活。激活失败保留在列表中,并拒绝新绑定,但不阻止应用启动。 + +注册表拥有各代际的 scope 与 Loader 树。Agent 将自身 scope 链接到选定代际,子 Agent 继承父方的确切代际。更新使旧代际退役;Agent 和临时历史读取者保留引用,直至释放。最后一个引用释放后,退役树被销毁。Host 注册表和 Agent loop 保持共享;preset 的服务提供方与消费者需要处于隔离 realm 中。 + +Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。 + +会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。现有选择器策略和空白会话选择保持不变:隐藏选择器使用部署默认值,但不删除保存的用户偏好。 + +## Alternatives considered + +**同时保留目录 preset 与声明。** 两个可写来源会争用标识,需要额外的优先级、迁移和编辑规则。普通 profile 配置已提供所需持久化和分层,因此 preset 不再有独立路径。 + +**让声明插件拥有运行中的子树。** 编辑时 Loader 释放插件会撤销运行中 Agent 的工具。注册表所有权使保留代际具有独立于声明的明确生命周期。 + +**首次选择时才加载。** 延迟激活推迟诊断,并增加首次使用的等待状态。提前激活适用于这些组装,且可在选择前暴露失败。 + +**Preset 损坏就拒绝应用启动。** 可选能力集失败后应仍能在 Web 中修复。健康定义和 Host 服务保持可用。 + +## Consequences + +同一进程可以运行能力不同的 Agent,同时共享各选定代际。退役代际在仍有引用时占用内存。Preset 不提供安全沙箱,用户覆盖会替换整个子插件列表,不会合并后续内置变更。 + +原有目录与逐会话挂载决策保留为[历史背景](../../archived/architecture/2026-08-03-per-session-agent-presets.md)。Host 服务所有权继续由 [Host 平面说明](2026-08-10-host-plane-ownership-after-presets.zh.md)记录。 + +## Testing + +注册表测试覆盖父子 scope 保留、历史读取租约、选择日志及激活失败。编辑器测试使用真实 profile 补丁,检查冲突拒绝并保留嵌套表达式。随附 Web 组装测试通过构建产物验证激活、作用域工具、Creator 技能和 profile 编辑。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml index 22806466c1..e690b45d08 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md -2026-08-10-child-agents-join-their-parent-preset.md: 330476ba14ce4a6708367b75e6e4d347e7cd4f86 -2026-08-10-child-agents-join-their-parent-preset.zh.md: a8bfb3afd5c0814d1438b90ae1dd959f94b637d3 +2026-08-10-child-agents-join-their-parent-preset.md: ee7dbabea3901760e1089dc3d24723999e0cd16d +2026-08-10-child-agents-join-their-parent-preset.zh.md: d62cd6a566fa0555531f059ff88eed71083cfac4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md index 330476ba14..ee7dbabea3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.md @@ -6,7 +6,7 @@ English | [中文](2026-08-10-child-agents-join-their-parent-preset.zh.md) ## Problem -Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. +Tool and prompt-section visibility is inherited along `dsh-scope`'s parent chain, and an agent's scope key is minted with no parent. [Per-session agent presets](../architecture/2026-09-18-declarative-agent-presets.md) moved every model-facing row onto the agent plane and made `AgentPresets.mount()` the one thing that binds that parent link — from the api-proxy's session create, resume, and fork paths. The two in-process subagent drivers compose their children through `applyChildComposition()`, which installed only the per-child persona and tool filter, so a child's scope chain had length one and its registry view resolved the global layer alone. That layer is now empty in any deployment with a preset roster: the web-app patch layer disables every host-plane tool row. A one-shot child therefore reached the model with zero tools, a continuable child with only the host-plane `report`, and neither carried its parent's persona, workspace context, plan-mode section, or skill catalog. The fork path had already been given the same treatment for the same reason; delegation had not. @@ -40,7 +40,7 @@ Giving the child its parent's tools exposed a second defect the same agent-plane ## Testing -`packages/preset/agent-presets/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. +`packages/preset/agent-preset-registry/tests/mount.spec.ts` covers the join against real fixture compositions: the child sees its parent's tools and prompt sections, no second generation is mounted, the join survives the parent's disposal (a background child outliving its parent), the reported id matches, a parent without a preset joins nothing, and an unscoped context is refused. `packages/core/tools/tests/scoped.spec.ts` covers the restriction rule directly: a child's filter removes a tool it inherited from an ancestor scope, the child's own registrations survive its own filter, and an ancestor's restriction still reaches every scope nested inside it. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md index a8bfb3afd5..d62cd6a566 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-child-agents-join-their-parent-preset.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-08-03-per-session-agent-presets.zh.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 +工具与提示段的可见性沿 `dsh-scope` 的父链继承,而 agent 的 scope key 铸造出来时没有父。[逐会话 agent preset](../architecture/2026-09-18-declarative-agent-presets.zh.md) 把所有面向模型的行搬到了 agent 平面,并让 `AgentPresets.mount()` 成为绑定那条父链的唯一途径——调用点在 api-proxy 的会话创建、恢复与 fork 路径上。两个进程内 subagent 驱动通过 `applyChildComposition()` 组装子 agent,而它只安装了逐子 agent 的 persona 与工具限制,于是子 agent 的 scope 链长度为一,其注册表视图只能解析到全局层。 在任何配置了 preset roster 的部署里,那一层现在是空的:web-app 补丁层禁用了全部宿主平面工具行。因此一次性子 agent 抵达模型时工具为零,可继续子 agent 只剩宿主平面的 `report`,两者都不带父方的 persona、工作区上下文、plan-mode 段与技能目录。fork 路径此前已因同一理由做过相同处理;委派没有。 @@ -40,7 +40,7 @@ Status: implemented ## 测试 -`packages/preset/agent-presets/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 +`packages/preset/agent-preset-registry/tests/mount.spec.ts` 用真实 fixture 组装覆盖该加入:子 agent 看到父方的工具与提示段、不会挂载出第二个代际、加入在父方 dispose 后依然成立(活得比父方久的后台子 agent)、上报的 id 一致、没有 preset 的父方不产生加入、以及无 scope 的上下文被拒绝。 `packages/core/tools/tests/scoped.spec.ts` 直接覆盖该限制规则:子 agent 的过滤器能移除它从祖先作用域继承来的工具、子 agent 自身的注册在自己的过滤器下存活、祖先的限制仍作用于其内嵌套的每个作用域。 diff --git a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml index 3410256a1a..9d38b3b53b 100644 --- a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md -2026-09-12-ralph-off-in-shipped-defaults.md: 4ae434a0e9d6283c2934cdf74a562df360daa12e -2026-09-12-ralph-off-in-shipped-defaults.zh.md: 490b33d85be411826d03c5def08b1905062ff830 +2026-09-12-ralph-off-in-shipped-defaults.md: fbd7ea72e02302f45029d2b7677d9a4d15ee2e63 +2026-09-12-ralph-off-in-shipped-defaults.zh.md: 377f8656236b55dc8de79a78abe742b595e3617c diff --git a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md index 4ae434a0e9..fbd7ea72e0 100644 --- a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md +++ b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.md @@ -16,7 +16,7 @@ That tool shipped enabled in `packages/bundle/base/cordis.patch.yml` and in thre The `ptc` preset also declares `workflow-ptc` disabled. That preset kept the engine for `ralph` alone after it dropped the general `workflow` tool, so disabling `ralph` left the engine with no consumer in that composition. -Each disabled row carries its restore recipe in a local comment. An overlay row re-enables the capability for a base-backed profile from `$DSH_HOME/cordis.patch.yml` or a `--patch` file. Preset files take no patches (`packages/preset/agent-presets/README.md`), so a Web session that wants `ralph` duplicates the preset under a **new id** into `$DSH_HOME/.agent-presets` and drops `disabled`; a copy reusing the shipped id is shadowed by the shipped root, which wins duplicate ids (`packages/preset/agent-presets/src/index.ts`), and `copy()` refuses an id any root already supplies. In `ptc` the duplicate drops `disabled` from the tool row and the engine row together, because `tool-ralph` injects `ctx.workflowEngine`. +Each disabled row carries its restore recipe in a local comment. An overlay row re-enables the capability for a base-backed profile from `$DSH_HOME/cordis.patch.yml` or a `--patch` file. Shipped presets are `@deepseek-ai/dsh-agent-preset` rows in the Web bundle patch (`packages/bundle/web-app/cordis.patch.yml`), so a Web session that wants `ralph` overrides that row's `config.plugins` from the profile patch, or saves a preset under a **new id** through the Web editor, which persists the declaration in `$DSH_HOME/profiles//cordis.patch.yml`; the registry refuses a duplicate preset id (`packages/preset/agent-preset-registry/src/index.ts`). In `ptc` the override drops `disabled` from the tool row and the engine row together, because `tool-ralph` injects `ctx.workflowEngine`. `packages/bundle/web-app/cordis.patch.yml` restates that disable for its own layer. `validatePresetPlaneSeparation` in `scripts/verify-cordis-config.ts` collects declared row ids without regard to `disabled`, so deleting that row would return `tool-ralph` to the Web host plane and collide with the same id in every preset. @@ -44,6 +44,6 @@ A default Web, headless, sdk, acp, or custom base-backed session no longer offer ## Verification -`packages/preset/agent-presets/tests/shipped-root.spec.ts` pins that every preset carrying `tool-ralph` disables it, that the `minimal` roster carries no such row, that `ptc` disables `workflow-ptc`, and that `standard` and `cordis` keep the engine enabled for their `workflow` tool. `apps/cli/tests/web-agent-presets.e2e.ts` and `apps/web/tests/shipped-composition.e2e.ts` pin the exact default and PTC tool catalogs, so a row that stops contributing is a test failure rather than a silently shorter list. `scripts/verify-cordis-config.ts` continues to pass its plane-separation check against the disabled rows. +`apps/cli/tests/web-agent-presets.e2e.ts` pins that every preset carrying `tool-ralph` disables it, that the `minimal` roster carries no such row, that `ptc` disables `workflow-ptc`, and that `standard` and `cordis` keep the engine enabled for their `workflow` tool. `apps/cli/tests/web-agent-presets.e2e.ts` and `apps/web/tests/shipped-composition.e2e.ts` pin the exact default and PTC tool catalogs, so a row that stops contributing is a test failure rather than a silently shorter list. `scripts/verify-cordis-config.ts` continues to pass its plane-separation check against the disabled rows. Most affected recorded-session sidecars were regenerated with `DSH_SNAPSHOT=refresh pnpm run test:snapshot`, and `pnpm run test:snapshot` replays the corpus. Six sidecars were curated by hand instead, because no local run produces them. Four belong to `pwsh-tool-turn` and `persistent-pwsh-tool-turn`, which this host skips for a missing `pwsh`; their removed text is byte-identical to what the refresh removed elsewhere. The remaining two are `snapshots/web/schedule-catalog`, which no executing test writes or reads: `schedule-after.e2e.ts` reads only that directory's `catalog.expected.md` and `session.v3.jsonl`, and `assertFixtureInventory` checks only that the four files exist, so the `header.pin: true` in its `snapshot.yml` is not enforced. `snapshots/session/ralph-loop` passes under its own composition patch, which is the evidence that the demotion removed the row from the defaults without removing coverage of the tool. diff --git a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md index 490b33d85b..377f865623 100644 --- a/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-12-ralph-off-in-shipped-defaults.zh.md @@ -16,7 +16,7 @@ Status: implemented `ptc` preset 还额外把 `workflow-ptc` 声明为禁用。该 preset 在去掉通用 `workflow` 工具之后只为 `ralph` 保留了这个引擎,因此禁用 `ralph` 使该组合中的引擎不再有消费方。 -每个被禁用的行都在本地注释里带上恢复方法。对基于 base 的档位,用一行 overlay 即可从 `$DSH_HOME/cordis.patch.yml` 或 `--patch` 文件重新启用。preset 文件不接受补丁(`packages/preset/agent-presets/README.md`),因此想要 `ralph` 的 Web 会话要以**新 id** 把 preset 复制到 `$DSH_HOME/.agent-presets` 并删掉 `disabled`;沿用随附 id 的副本会被随附 root 遮蔽,因为重复 id 由更靠前的 root 胜出(`packages/preset/agent-presets/src/index.ts`),而 `copy()` 也拒绝任何 root 已提供的 id。在 `ptc` 中,副本要同时删掉工具行和引擎行的 `disabled`,因为 `tool-ralph` 注入 `ctx.workflowEngine`。 +每个被禁用的行都在本地注释里带上恢复方法。对基于 base 的档位,用一行 overlay 即可从 `$DSH_HOME/cordis.patch.yml` 或 `--patch` 文件重新启用。随附 preset 是 Web bundle 补丁(`packages/bundle/web-app/cordis.patch.yml`)里的 `@deepseek-ai/dsh-agent-preset` 行,因此想要 `ralph` 的 Web 会话可以在 profile 补丁里覆盖该行的 `config.plugins`,或通过 Web 编辑器以**新 id** 保存一个 preset,声明会持久化到 `$DSH_HOME/profiles//cordis.patch.yml`;注册表拒绝重复的 preset id(`packages/preset/agent-preset-registry/src/index.ts`)。在 `ptc` 中,覆盖要同时删掉工具行和引擎行的 `disabled`,因为 `tool-ralph` 注入 `ctx.workflowEngine`。 `packages/bundle/web-app/cordis.patch.yml` 在自己的层里重述这条禁用声明。`scripts/verify-cordis-config.ts` 中的 `validatePresetPlaneSeparation` 在收集已声明的行 id 时不看 `disabled`,所以删掉这一行会让 `tool-ralph` 回到 Web 宿主平面,并与每个 preset 中的同名行冲突。 @@ -44,6 +44,6 @@ Status: implemented ## 验证 -`packages/preset/agent-presets/tests/shipped-root.spec.ts` 固定了四点:每个带 `tool-ralph` 的 preset 都禁用它;`minimal` 清单不含该行;`ptc` 禁用 `workflow-ptc`;`standard` 与 `cordis` 为各自的 `workflow` 工具保留引擎启用。`apps/cli/tests/web-agent-presets.e2e.ts` 与 `apps/web/tests/shipped-composition.e2e.ts` 固定默认与 PTC 的确切工具目录,因此某一行不再贡献会直接导致测试失败,而不是让列表悄悄变短。`scripts/verify-cordis-config.ts` 的平面隔离检查在禁用行存在的情况下继续通过。 +`apps/cli/tests/web-agent-presets.e2e.ts` 固定了四点:每个带 `tool-ralph` 的 preset 都禁用它;`minimal` 清单不含该行;`ptc` 禁用 `workflow-ptc`;`standard` 与 `cordis` 为各自的 `workflow` 工具保留引擎启用。`apps/cli/tests/web-agent-presets.e2e.ts` 与 `apps/web/tests/shipped-composition.e2e.ts` 固定默认与 PTC 的确切工具目录,因此某一行不再贡献会直接导致测试失败,而不是让列表悄悄变短。`scripts/verify-cordis-config.ts` 的平面隔离检查在禁用行存在的情况下继续通过。 多数受影响的录制会话旁挂文件由 `DSH_SNAPSHOT=refresh pnpm run test:snapshot` 重新生成,并由 `pnpm run test:snapshot` 回放整个语料。另有六个旁挂文件是人工整理,因为本机没有任何一次运行会产出它们。其中四个属于 `pwsh-tool-turn` 与 `persistent-pwsh-tool-turn`:本机缺少 `pwsh`,这两个场景在本地被跳过;它们删掉的文本与刷新在别处删掉的文本逐字节相同。剩下两个是 `snapshots/web/schedule-catalog`,没有任何在执行中的测试写入或读取它们:`schedule-after.e2e.ts` 只读该目录的 `catalog.expected.md` 与 `session.v3.jsonl`,`assertFixtureInventory` 也只检查那四个文件存在,因此它 `snapshot.yml` 里的 `header.pin: true` 并未被强制执行。`snapshots/session/ralph-loop` 在它自己的组合补丁下通过,这证明这次降级把该行移出了默认组合,却没有移除对该工具的覆盖。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 1fb5a7e132..e355fd822a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -18,15 +18,6 @@ "lib/*.js", "lib/types/*.d.ts" ], - "dsh": { - "configTrees": [ - { - "mount": "config/agent-presets", - "path": "../../packages/preset/agent-presets/presets", - "scanRoster": true - } - ] - }, "license": "MIT", "dependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -107,6 +98,7 @@ "@deepseek-ai/dsh-mcp-resources": "workspace:^", "@deepseek-ai/dsh-plugin-manager": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-agent-preset": "workspace:^", "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^" }, "devDependencies": { diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index b75e63caf2..4811110751 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,11 +1,12 @@ +import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' -import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' +import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { - boot, initProfile, createRuntimeResolution, loadOverlayPatches, @@ -19,7 +20,10 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings' -import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' +import { dump, load } from 'js-yaml' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { bundlePatchPaths, composeEntries } from '@deepseek-ai/dsh-app-boot' +const SETTINGS_NAMESPACE = 'agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-compaction-basic' @@ -30,9 +34,12 @@ import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-token-meter' const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) -/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */ +const { boot } = createRequire(import.meta.url)(join(REPO_ROOT, 'packages/boot/app-boot/lib/index.js')) as typeof import('@deepseek-ai/dsh-app-boot') +/** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty profile. */ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') -const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') +const WEB_BUNDLE = join(REPO_ROOT, 'packages/bundle/web-app') +const WEB_PATCHES = bundlePatchPaths(WEB_BUNDLE, (JSON.parse(readFileSync(join(WEB_BUNDLE, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string[] } } }).dsh.bundle) +const webPatches = (label: string): PatchOptions[] => WEB_PATCHES.flatMap(file => loadOverlayPatches(label, file)) const CODEX_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-codex') const CLAUDE_CODE_PACKAGE_DIR = join(REPO_ROOT, 'packages/subagent/subagent-claude-code') /** The installation anchor whose dependency surface the runtime resolution mirrors. */ @@ -62,8 +69,7 @@ async function bootWeb( // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it // reads the developer's own document — and since the default preset is a // setting, a stored `agent-presets.default` would decide this file's - // outcome. Point it at a temp file for the same reason the roster row - // below pins `includeUserRoot` off. + // outcome. Point it at a temporary file to isolate settings. { id: 'settings', config: { path: settingsFile, watch: false } }, // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this // file writes the developer's own `~/.dsh/storages/` — and then reads it @@ -114,12 +120,7 @@ async function bootWeb( { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }, { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' }, ] }, - // Pin the roster away from the developer's machine: `includeUserRoot` - // false keeps `~/.dsh/.agent-presets` from changing a test's outcome. - // `default` here is the COMPOSITION default — the base layer the settings - // document overrides. No `roots` entry: the plugin bundles the shipped - // presets itself and prepends their root. - { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { id: 'agent-preset-registry', config: { default: 'standard' } }, ...extra, ] const home = dirname(settingsFile) @@ -145,7 +146,7 @@ async function bootWeb( } let bundlePatches: PatchOptions[] = [ ...loadOverlayPatches('dsh-test', BASE_PATCH), - ...loadOverlayPatches('dsh-test', WEB_PATCH), + ...webPatches('dsh-test'), ] if (profileBundles !== undefined) { await writeFile(join(profileDir, 'package.json'), JSON.stringify({ @@ -156,6 +157,9 @@ async function bootWeb( profile = loadProfile('dsh-test', 'spec', INSTALL_ANCHOR, home, { userLayer: false }) bundlePatches = profile.layers.flatMap(layer => layer.patches) } + const declarations = overrides.filter(patch => 'insert' in patch) + await writeFile(profile.patchPath, dump(declarations, { schema: entryListSchema })) + const launchOverrides = overrides.filter(patch => !('insert' in patch)) const resolution = await createRuntimeResolution({ installAnchor: INSTALL_ANCHOR, home, profile }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') @@ -163,7 +167,7 @@ async function bootWeb( bootCtx.provide('profileContext', { name: 'spec', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR, home, cwd: home, startedBundles: profileBundles ?? ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], - overlays: overrides, telemetryDisabledEnv: '1' }) + overlays: launchOverrides, telemetryDisabledEnv: '1' }) await bootCtx.plugin(PluginPackages, { resolution }) bootCtx.provide('connection', { fetch: { register: () => () => {} }, @@ -187,15 +191,13 @@ function toolParameterNames(ctx: Context, agent: Agent, toolName: string): strin } function enablePresetTool(composition: string, id: string): string { - const row = ` - id: ${id}\n` - const start = composition.indexOf(row) - if (start < 0) throw new Error(`missing preset row ${id}`) - const end = composition.indexOf('\n - id:', start + row.length) - const disabled = composition.indexOf(' disabled: true\n', start) - if (disabled < 0 || (end >= 0 && disabled > end)) { - throw new Error(`preset row ${id} is not disabled`) - } - return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) + const rows = load(composition, { schema: entryListSchema }) as import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] + const visit = (entries: typeof rows): boolean => entries.some((row) => { + if (row.id === id) { row.disabled = false; return true } + return row.group === true && visit(row.config as typeof rows) + }) + if (!visit(rows)) throw new Error(`missing preset row ${id}`) + return dump(rows, { schema: entryListSchema }) } let ctx: Context @@ -244,7 +246,7 @@ describe('the shipped Web composition', () => { const listed = await ctx.agentPresets.list() expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard']) - expect(listed.every(preset => preset.trust === 'system')).toBe(true) + expect(listed.every(preset => !('path' in preset))).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -369,9 +371,15 @@ describe('the shipped Web composition', () => { expect(tools).not.toContain('str_replace_editor') expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() - // The persona is `standard`'s, pinned verbatim so the two composition - // files cannot drift apart: tool descriptions and the skill catalog - // carry every creation-mode instruction. + // The preset's own authoring skill registers into ITS layer of the host + // registry: the cordis agent's view carries it, the global view does not. + const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) + expect(scoped).toContain('editing-cordis-compositions') + expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions') + + // The persona is `standard`'s, pinned verbatim so the two declarations + // cannot drift apart: tool descriptions and the skill catalog carry + // every creation-mode instruction. const standard = await ctx.agents.create({ sessionId: SessionId('preset-cordis-standard-persona'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), @@ -385,12 +393,6 @@ describe('the shipped Web composition', () => { } finally { await standard.dispose() } - - // The preset's own authoring skill registers into ITS layer of the host - // registry: the cordis agent's view carries it, the global view does not. - const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) - expect(scoped).toContain('editing-cordis-compositions') - expect((await ctx.skills.list()).map(skill => skill.name)).not.toContain('editing-cordis-compositions') } finally { await handle.dispose() } @@ -442,11 +444,9 @@ describe('the shipped Web composition', () => { } }) - it('ships the composition-authoring skill inside the preset directory', async () => { - // The preset's skill root is derived from its own `baseUrl`, so the skill - // travels with the directory wherever the preset is installed. + it('ships the composition-authoring skill in the declaration package', async () => { const skill = join( - SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md', + REPO_ROOT, 'packages/preset/agent-preset', 'skills', 'editing-cordis-compositions', 'SKILL.md', ) expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true) @@ -513,13 +513,12 @@ describe('the shipped Web composition', () => { } }) - it('never rewrites the preset file it composed from', async () => { + it('never rewrites the shipped profile patch when an Agent is disposed', async () => { // The Loader persists a tree whose plugin self-disposed, and tearing an // agent down disposes its whole subtree. Inherited, that rewrote the // shipped composition — truncating it to `[]` the first time a session // ended — so `PresetTree` refuses to write at all. - const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml') - const before = await readFile(path, 'utf8') + const before = await Promise.all(WEB_PATCHES.map(file => readFile(file, 'utf8'))) const handle = await ctx.agents.create({ sessionId: SessionId('preset-readonly'), @@ -533,7 +532,7 @@ describe('the shipped Web composition', () => { // so any wait past settlement fails; a longer one only slows the test. await new Promise(resolve => setTimeout(resolve, 50)) - expect(await readFile(path, 'utf8')).toBe(before) + expect(await Promise.all(WEB_PATCHES.map(file => readFile(file, 'utf8')))).toEqual(before) }) }) @@ -544,9 +543,10 @@ describe('product Bundle and user-preset intersection', () => { async function bootProducts(installed: readonly Product[]): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) - const userRoot = join(root, 'presets') + const definitions: import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] = [] const settingsFile = join(root, 'settings.yaml') - const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8') + const standardConfig = composeEntries([webPatches('test')]).find(row => row.id === 'preset-standard')!.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition + const standard = dump(standardConfig.plugins, { schema: entryListSchema }) await writeFile(settingsFile, '{}\n') for (const id of presetIds) { let composition = standard @@ -556,9 +556,7 @@ describe('product Bundle and user-preset intersection', () => { if (id === 'products-claude' || id === 'products-both') { composition = enablePresetTool(composition, 'tool-subagent-claude-code') } - const directory = join(userRoot, id) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'agent.cordis.yml'), composition) + definitions.push({ id: `preset-${id}`, name: '@deepseek-ai/dsh-agent-preset', config: { id, plugins: load(composition, { schema: entryListSchema }) } }) } const packageDir = (product: Product): string => ( product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR @@ -568,17 +566,7 @@ describe('product Bundle and user-preset intersection', () => { ? '@deepseek-ai/dsh-subagent-codex' : '@deepseek-ai/dsh-subagent-claude-code' ) - return await bootWeb(settingsFile, [ - { - id: 'agent-presets', - config: { - default: 'standard', - // The shipped root is the plugin's own, prepended before this. - roots: [{ path: userRoot, trust: 'user' }], - includeUserRoot: false, - }, - }, - ], installed.map(packageDir), [ + return await bootWeb(settingsFile, [{ insert: definitions } ], installed.map(packageDir), [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...installed.map(packageName), @@ -637,38 +625,9 @@ describe('product Bundle and user-preset intersection', () => { } } }, 120_000) - - it('applies a product-row edit only to later sessions on the preset', async () => { - const productCtx = await bootProducts(['codex']) - const preset = await productCtx.agentPresets.resolve('products-none') - const original = await readFile(preset.path, 'utf8') - const existing = await productCtx.agents.create({ - sessionId: SessionId('preset-product-generation-existing'), - setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), - }) - try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') - await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) - - const later = await productCtx.agents.create({ - sessionId: SessionId('preset-product-generation-later'), - setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), - }) - try { - expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') - expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') - } finally { - await later.dispose() - } - } finally { - await existing.dispose() - await writeFile(preset.path, original) - await productCtx.fiber.dispose() - } - }, 120_000) }) -describe('a user preset copied from a shipped one', () => { +describe('a user preset declared from the shipped cordis rows', () => { /** The JSON text a `cordis_inspect_*` result renders. */ function resultText(result: { content: ReadonlyArray<{ type: string; text?: string }> }): string { return result.content.filter(part => part.type === 'text').map(part => part.text ?? '').join('') @@ -680,14 +639,13 @@ describe('a user preset copied from a shipped one', () => { // `tool-cordis` row only registers the tools. Before that split the copy // failed to mount: its row re-registered provider "Service". const root = await mkdtemp(join(tmpdir(), 'dsh-copied-preset-')) - const userRoot = join(root, 'presets') - await cp(join(SHIPPED_PRESET_ROOT, 'cordis'), join(userRoot, 'cordis-copy'), { recursive: true }) const settingsFile = join(root, 'settings.yaml') await writeFile(settingsFile, '{}\n') - const copyCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { default: 'standard', roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false }, - }]) + const cordis = composeEntries([webPatches('test')]).find(row => row.id === 'preset-cordis')!.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition + const copyCtx = await bootWeb(settingsFile, [{ insert: [{ + id: 'preset-cordis-copy', name: '@deepseek-ai/dsh-agent-preset', + config: { ...cordis, id: 'cordis-copy', name: 'Cordis copy' }, + }] }]) try { const shipped = await copyCtx.agents.create({ sessionId: SessionId(`preset-cordis-shipped-${randomUUID()}`), @@ -851,137 +809,6 @@ describe('a delegated child', () => { }) }) -describe('a launcher that configures no writable root', () => { - // The claim this default exists for, asserted through the real shipped - // bundles rather than a hand-built context: `apps/cli` patches in only the - // system root, and a person's own presets are found anyway because the - // roster derives `/.agent-presets` itself. `$DSH_HOME` is pointed - // at a temp home BEFORE boot — the derived root is resolved when the plugin - // is constructed, and an unpinned run would read the developer's own. - let derivedCtx: Context - let previousHome: string | undefined - - beforeAll(async () => { - const home = await mkdtemp(join(tmpdir(), 'dsh-preset-derived-')) - previousHome = process.env.DSH_HOME - process.env.DSH_HOME = home - await mkdir(join(home, '.agent-presets', 'derived-mine'), { recursive: true }) - await writeFile( - join(home, '.agent-presets', 'derived-mine', 'agent.cordis.yml'), - '- id: tool-todo\n name: \'@deepseek-ai/dsh-tool-todo\'\n config:\n allowParallelInProgress: true\n', - ) - const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml') - await writeFile(settingsFile, '{}\n') - // No configured roots: the shipped one is the plugin's own, and the - // writable one is the roster's own default rather than this patch's job. - derivedCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { default: 'standard', includeUserRoot: true }, - }]) - }, 120_000) - - afterAll(async () => { - if (previousHome === undefined) delete process.env.DSH_HOME - else process.env.DSH_HOME = previousHome - await derivedCtx.fiber.dispose() - }) - - it('discovers and mounts a preset the person authored under the harness home', async () => { - const listed = await derivedCtx.agentPresets.list() - - const mine = listed.find(preset => preset.id === 'derived-mine') - expect(mine).toMatchObject({ trust: 'user' }) - // Omitted rather than undefined: a healthy row carries no `broken` key. - expect(mine?.broken).toBeUndefined() - expect(derivedCtx.agentPresets.authorable).toBe(true) - - const handle = await derivedCtx.agents.create({ - sessionId: SessionId('preset-derived-root'), - setup: agentCtx => derivedCtx.agentPresets.mount(agentCtx, 'derived-mine').then(() => undefined), - }) - try { - expect(toolNames(derivedCtx, handle.agent)).toContain('todo_write') - } finally { - await handle.dispose() - } - }) -}) - -describe('authoring a preset on the shipped composition', () => { - let authorCtx: Context - let userRoot: string - - beforeAll(async () => { - userRoot = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')), 'profiles') - const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-settings-')), 'settings.yaml') - await writeFile(settingsFile, '{}\n') - authorCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { - default: 'standard', - // The root does not exist yet: a deployment whose user has authored - // nothing is the normal first-run state. The shipped root is the - // plugin's own, prepended before this. - roots: [{ path: userRoot, trust: 'user' }], - includeUserRoot: false, - }, - }]) - }) - - it('refuses to copy over or delete a shipped preset', async () => { - await expect(authorCtx.agentPresets.copy('minimal', 'standard')).rejects.toThrow(/already exists/) - await expect(authorCtx.agentPresets.remove('standard')).rejects.toThrow(/ships with the deployment/) - }) - - it.each(['../escape', 'a/b', '/abs', 'Upper'])('refuses the uncontainable id %j', async (id) => { - // The id becomes a directory name under the user root, so containment is - // checked on the id rather than on the joined path afterwards. - await expect(authorCtx.agentPresets.copy('minimal', id)).rejects.toThrow() - }) - - it('copies a shipped preset a session then really composes from', async () => { - await authorCtx.agentPresets.copy('minimal', 'my-agent', '我的模式') - - // Round-trips through the roster as a `user` row carrying the given name - // and the source's description, over the source's own composition text. - const preset = await authorCtx.agentPresets.resolve('my-agent') - const source = await authorCtx.agentPresets.resolve('minimal') - expect(preset.trust).toBe('user') - expect(preset.name).toBe('我的模式') - expect(preset.description).toBe(source.description) - expect(await authorCtx.agentPresets.read('my-agent')).toBe(await authorCtx.agentPresets.read('minimal')) - // Owner-only, in an owner-only directory: a composition is executable - // configuration on a machine that may have other users. - expect((await stat(preset.path)).mode & 0o777).toBe(0o600) - const handle = await authorCtx.agents.create({ - sessionId: SessionId('preset-authored'), - setup: agentCtx => authorCtx.agentPresets.mount(agentCtx, 'my-agent').then(() => undefined), - }) - try { - // The same tools the shipped `minimal` composes, from a directory copied - // through the service into a root outside the installed harness. - expect(toolNames(authorCtx, handle.agent)).toEqual(['bash']) - } finally { - await handle.dispose() - } - }) - - it('deletes what it copied', async () => { - await authorCtx.agentPresets.copy('minimal', 'doomed') - - await authorCtx.agentPresets.remove('doomed') - - expect((await authorCtx.agentPresets.list()).map(preset => preset.id)).not.toContain('doomed') - }) -}) - -/** - * Which preset an unnamed session gets is a user setting layered over the - * composition's own default. The package suite proves the layering against a - * hand-built context; this proves it through the shipped `cordis.yml` — that - * the roster and the settings provider are actually wired to each other, and - * that the id the setting names is the one a session composes from. - */ describe('the default preset as a user setting', () => { it('composes an unnamed session from the stored default, not the composed one', async () => { expect((await ctx.agentPresets.remoteExportList()).modeSelectionEnabled).toBe(true) @@ -1031,63 +858,4 @@ describe('a session keeps the preset it was created with', () => { }) }) -describe('a composition that configures its own preset roots', () => { - let rootsCtx: Context - let teamRoot: string - - beforeAll(async () => { - const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-')) - const settingsFile = join(home, 'settings.yaml') - await writeFile(settingsFile, '{}\n') - // A workspace-shared root beside the deployment: one preset of its own, - // plus a directory that claims a shipped id. - teamRoot = join(home, 'team-presets') - const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8') - for (const id of ['team-spec', 'minimal']) { - await mkdir(join(teamRoot, id), { recursive: true }) - await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition) - } - // The user layer of the reported regression: a profile's cordis.patch.yml - // configuring a shared preset root. The plugin must EXTEND it with its - // own shipped root, never lose it. - rootsCtx = await bootWeb(settingsFile, [{ - id: 'agent-presets', - config: { - default: 'standard', - roots: [{ path: teamRoot, trust: 'user' }], - includeUserRoot: false, - }, - }]) - }, 120_000) - - afterAll(async () => { - await rootsCtx.fiber.dispose() - }) - - it('keeps configured roots alongside the always-prepended shipped root', async () => { - expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([ - SHIPPED_PRESET_ROOT, - teamRoot, - ]) - - const listed = await rootsCtx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard', 'team-spec']) - expect(listed.every(preset => preset.broken === undefined)).toBe(true) - // The shipped root comes first: a configured directory claiming a shipped - // id is shadowed, never the other way around. - expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system') - expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user') - }) - - it('composes an agent from a configured-root preset', async () => { - const handle = await rootsCtx.agents.create({ - sessionId: SessionId('preset-team-spec'), - setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined), - }) - try { - expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash']) - } finally { - await handle.dispose() - } - }) -}) +afterAll(async () => { await ctx?.fiber.dispose() }) diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 5c9045e441..c10f7c869a 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -18,8 +18,7 @@ import { fileURLToPath } from 'node:url' import yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { evaluate } from '@deepseek-ai/cordis-plugin-loader' -import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets' -import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' +import { bundlePatchPaths, composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot' /** * The effective disabled state of one row on one platform: a `!!js` expression @@ -102,13 +101,15 @@ describe('the shipped shell composition (real bundle layers)', () => { }) describe('shipped agent presets gate both shell tools by platform', () => { - const presetRoot = SHIPPED_PRESET_ROOT + const webBundle = fileURLToPath(new URL('../../../packages/bundle/web-app/', import.meta.url)) + const webManifest = JSON.parse(readFileSync(join(webBundle, 'package.json'), 'utf8')) as { dsh: { bundle: { patch: string[] } } } + const presetRows = composeEntries([bundlePatchPaths(webBundle, webManifest.dsh.bundle).flatMap(file => + yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema }) as import('@deepseek-ai/cordis-plugin-include').PatchOptions[])]) + + const definitions = presetRows.filter(row => row.name === '@deepseek-ai/dsh-agent-preset').map(row => row.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition) it.each(['standard', 'ptc', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { - const entries: unknown = yaml.load( - readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'), - { schema: entryListSchema }, - ) + const entries: unknown = definitions.find(row => row.id === preset)!.plugins if (!Array.isArray(entries)) throw new TypeError(`preset ${preset} must parse to an entry array`) for (const [id, win32] of [['tool-bash', true], ['tool-pwsh', false]] as const) { const row = entries.find((entry): entry is Record => ( @@ -124,10 +125,7 @@ describe('shipped agent presets gate both shell tools by platform', () => { }) it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => { - const entries: unknown = yaml.load( - readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), - { schema: entryListSchema }, - ) + const entries: unknown = definitions.find(row => row.id === 'minimal')!.plugins if (!Array.isArray(entries)) throw new TypeError('minimal preset must parse to an entry array') for (const id of ['tool-bash', 'tool-pwsh']) { expect(entries.some(entry => ( diff --git a/apps/desktop/scripts/test-host-updates.ts b/apps/desktop/scripts/test-host-updates.ts index c9b7a991e1..ba0a63b8f6 100644 --- a/apps/desktop/scripts/test-host-updates.ts +++ b/apps/desktop/scripts/test-host-updates.ts @@ -28,7 +28,7 @@ try { { id: 'session-title-llm', disabled: true }, { id: 'session-telemetry-otel', disabled: true }, { id: 'agent-instructions', disabled: true }, - { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { id: 'agent-preset-registry', config: { default: 'standard' } }, { insert: [{ id: 'update-qualification', name: new URL('../tests/fixtures/host-update-control.mjs', import.meta.url).href }] }, ])) const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index 245007a7ab..669d572b9d 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -52,7 +52,7 @@ try { { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, { id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true }, { id: 'session-telemetry-otel', disabled: true }, - { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } }, + { id: 'agent-preset-registry', config: { default: 'standard' } }, { insert: [{ id: 'update-control', name: new URL('../tests/fixtures/workspace-update-host.mjs', import.meta.url).href }] }, ])) const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => diff --git a/apps/web/index.html b/apps/web/index.html index 75c4e5bac6..f5c2ffac06 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,7 +4,8 @@ - + + DSH Local Build diff --git a/apps/web/public/favicon-dark.svg b/apps/web/public/favicon-dark.svg new file mode 100644 index 0000000000..648fe4f2b9 --- /dev/null +++ b/apps/web/public/favicon-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index c92f15d43b..754615a37a 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,8 +1,3 @@ - diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 2572b1a5ff..51c250d8e3 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -1,315 +1,70 @@ -// Web e2e scenario: the agent-preset settings section as copy-only authoring. -// The browser never edits composition text — a shipped preset opens in a -// read-only viewer, the copy dialog collects an id and an optional display -// name, and the host copies the whole directory. The section's other job is -// getting the user TO the files: this lane pins `nativeOpen: false` (see the -// overlay), so the location affordance answers the preset directory as text — -// the deterministic branch a golden can hold on every platform. -// -// Zero model calls: no replay fixture mounts, so a stray stream fails loud. -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +/** The preset settings page only views and selects; authoring is guided to Creator mode. */ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import type { Locator } from 'playwright' -import { - captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, - webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' -const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url)) -const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') -const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md') -const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md') -const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md') -/** The shipped roster, bundled inside the `dsh-agent-presets` package. */ -const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url)) -const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url)) -const MODE = webSnapshotMode() +const EXPECTED = fileURLToPath(new URL('./expected/agent-preset-authoring', import.meta.url)) +const mode = webSnapshotMode() -describe('web e2e: agent-preset authoring is a host-side copy', () => { +describe('web e2e: preset roster guidance', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType - let userRoot: string - - /** The settings dialog, opened on the Agent-presets section. */ - function settingsDialog(): Locator { - return page.getByRole('dialog', { name: '设置' }) - } - beforeAll(async () => { - userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) - scaffold = await launchWebScaffold({ - extraOverlayPath: OVERLAY, - profile: { packages: [] }, - agentPresets: { - // The shipped root is the plugin's own, prepended before this. - roots: [{ path: userRoot, trust: 'user' }], - default: 'standard', - }, - }) + scaffold = await launchWebScaffold({ profile: { packages: [] } }) browser = await chromium.launch() - // The scenario asserts the shipped Chinese copy, so the browser asks for it. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.getByRole('button', { name: '设置', exact: true }).click() + await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: 'Agent 预设' }).click() + await page.getByRole('heading', { name: 'Agent 预设' }).waitFor() }, 120_000) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) - afterAll(async () => { - await browser?.close() - await scaffold?.close() - await rm(userRoot, { recursive: true, force: true }) + it('shows the shipped roster with mode help and no editing actions', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-section')) + await expect.poll(() => page.locator('[data-agent-preset-id]').count()).toBe(4) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(EXPECTED, 'section.expected.md'), snapshot, mode) + expect(snapshot).toContain('如何创建或修改预设') + expect(snapshot).not.toContain('新建预设') + expect(snapshot).not.toContain('编辑插件') + expect(snapshot).not.toContain('打开目录') + expect(snapshot).not.toContain('删除') }) - it('offers the roster with copy as the only way to create', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-section')) - await page.getByRole('button', { name: '设置', exact: true }).click() - const dialog = settingsDialog() - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) - // The intro copy also names 标准模式. Wait for the roster's own action so - // the snapshot cannot land between the section shell and its cards. - await dialog.getByRole('button', { name: '查看配置: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) - - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) - - await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) - const toggle = dialog.getByRole('switch', { name: '新任务可选择模式' }) - expect(await toggle.getAttribute('aria-checked')).toBe('true') - // The authoring entry is visible, and the shipped rows offer - // view/copy but never delete or a location — their - // install is overwritten by upgrades and is not the user's to manage. - expect(snapshot).toContain('让 Agent 帮我创建预设模式') - expect(snapshot).not.toContain('新建预设') - expect(snapshot).toContain('查看配置: 标准模式') - expect(snapshot).not.toContain('删除: 标准模式') - expect(snapshot).not.toContain('打开目录') - // The rest of this scenario exercises the existing default and Creator - // actions with the beta picker enabled by default. - }, 60_000) - - it('reads technical details and examples without changing the new-task default', async () => { - const settings = settingsDialog() + it('reads mode details and examples without changing the new-task default', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-guide')) + const settings = page.getByRole('dialog', { name: '设置' }) const trigger = settings.getByRole('button', { name: '模式说明: PTC 模式', exact: true }) await trigger.click() const guide = page.getByRole('dialog', { name: 'PTC 模式', exact: true }) await guide.getByRole('heading', { name: '怎样调用工具', exact: true }).waitFor() - const beforeSwitch = await guide.boundingBox() - expect(await guide.textContent()).toContain('run_code') await guide.getByRole('tab', { name: '如何使用', exact: true }).click() await guide.getByRole('heading', { name: '批量检查配置文件', exact: true }).waitFor() - expect(await guide.boundingBox()).toEqual(beforeSwitch) - await guide.getByRole('tab', { name: '如何使用', exact: true }).press('ArrowLeft') - expect(await guide.getByRole('tab', { name: '模式说明', exact: true }).getAttribute('aria-selected')).toBe('true') - await guide.getByRole('tab', { name: '模式说明', exact: true }).press('Escape') + await guide.getByRole('tab', { name: '如何使用', exact: true }).press('Escape') await guide.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => document.activeElement === element)).toBe(true) - expect(await settings.isVisible()).toBe(true) expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') - - await settings.getByRole('button', { name: '如何使用: 创造模式', exact: true }).click() - const creator = page.getByRole('dialog', { name: '创造模式', exact: true }) - await creator.getByRole('heading', { name: '添加一个界面', exact: true }).waitFor() - expect(await creator.getByRole('heading', { name: '添加一个工具', exact: true }).isVisible()).toBe(true) - const closePosition = await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox() - const examples = creator.getByRole('tabpanel', { name: '如何使用', exact: true }) - const scrolledTo = await examples.evaluate((element) => { - element.scrollTop = element.scrollHeight - return element.scrollTop - }) - expect(scrolledTo).toBeGreaterThan(0) - expect(await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()).toEqual(closePosition) - await creator.getByRole('tab', { name: '模式说明', exact: true }).click() - await creator.getByRole('tab', { name: '如何使用', exact: true }).click() - expect(await examples.evaluate(element => element.scrollTop)).toBe(scrolledTo) - await creator.getByRole('button', { name: '关闭', exact: true }).click() - await creator.waitFor({ state: 'detached' }) - }, 60_000) - - it('views a shipped composition read-only instead of editing it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '查看配置: 标准模式' }).click() - const viewer = page.getByRole('dialog', { name: '查看配置 · 标准模式' }) - await viewer.waitFor({ timeout: 10_000 }) - - // The real shipped composition, not a golden: the viewer shows whatever - // the deployment ships, and this lane only asserts it is shown read-only. - const shipped = await readFile(join(SHIPPED_PRESETS, 'standard', 'agent.cordis.yml'), 'utf8') - expect(await viewer.locator('pre').textContent()).toBe(shipped) - expect(await viewer.getByRole('textbox').count()).toBe(0) - // The header X and the footer button share the 关闭 name; the footer one - // is last in the dialog. - await viewer.getByRole('button', { name: '关闭' }).last().click() - await viewer.waitFor({ state: 'detached', timeout: 10_000 }) - }, 60_000) - - it('copies 极简模式 whole under a new id and lands in its files', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() - const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) - await copyDialog.waitFor({ timeout: 10_000 }) - - const dialogSnapshot = await captureStableAria( - page, '[role="dialog"][aria-label^="复制预设"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(COPY_DIALOG_EXPECTED, dialogSnapshot, MODE) - // Two fields and nothing else: the id is the directory name the host - // needs up front; description and composition live in the files. - expect(dialogSnapshot).toContain('标识符') - expect(dialogSnapshot).not.toContain('描述') - - await copyDialog.getByPlaceholder('my-agent').fill('my-agent') - await copyDialog.getByPlaceholder('在模式列表中显示的名称,不填则使用标识符').fill('我的模式') - await copyDialog.getByRole('button', { name: '创建' }).click() - await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) - - // The new row lands in the custom group, and — with no desktop opener — - // its directory is revealed as text right away: landing in the files is - // the completion of a copy, not a follow-up. - await dialog.getByText('我的模式').first().waitFor({ timeout: 10_000 }) - await dialog.getByText('预设文件:').waitFor({ timeout: 10_000 }) - // The copy dialog is detached, so the settings dialog is the only one - // left (it names itself via aria-labelledby, which a CSS attribute - // selector cannot address). - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { - replacements: [[userRoot, '{{presetRoot}}']], - }) - await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) - expect(snapshot).toContain('{{presetRoot}}/my-agent') - - // The host copied the whole directory and rewrote only the display - // metadata: the composition is byte-identical to the shipped source, the - // description rides along for the user to edit in place, and neither the - // source's name nor its roster order survives into the copy. - const composition = await readFile(join(userRoot, 'my-agent', 'agent.cordis.yml'), 'utf8') - expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) - const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') - expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 仅提供持久 shell 的单工具编码 Agent。') - expect(metadata).not.toContain('order:') - }, 60_000) - - it('deletes the copy after confirmation and reclaims the roster', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-delete')) - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '删除: 我的模式' }).click() - const confirm = page.getByRole('dialog', { name: '删除该预设?' }) - await confirm.waitFor({ timeout: 10_000 }) - await confirm.getByRole('button', { name: '删除', exact: true }).click() - await confirm.waitFor({ state: 'detached', timeout: 10_000 }) - - await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) - expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) - // The custom group outlives its only member: the heading stays with the - // creator entry so the place to author a preset never disappears. - expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) - expect(await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).count()).toBe(1) - expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) - }, 60_000) - - it('marks damaged presets broken and clears a ghost through delete', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-damaged')) - // The two hand-edit damage shapes: a composition that no longer parses, - // and a directory whose composition file was deleted outright. - await mkdir(join(userRoot, 'broken-yaml'), { recursive: true }) - await writeFile(join(userRoot, 'broken-yaml', 'agent.cordis.yml'), '- id: x\n name: [unclosed\n') - await mkdir(join(userRoot, 'ghost'), { recursive: true }) - await writeFile(join(userRoot, 'ghost', 'preset.yml'), 'name: 幽灵预设\ndescription: composition 已被手动删除。\n') - - // The section reads the roster when it mounts; hop away and back. - const dialog = settingsDialog() - await dialog.getByRole('button', { name: '通用设置' }).click() - await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 }) - - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { - replacements: [[userRoot, '{{presetRoot}}']], - }) - await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) - // Both damage shapes surface as marked, unselectable, uncopyable cards - // that still carry their metadata and the discovery-reported reason. - expect(snapshot).toContain('加载失败: broken-yaml') - expect(snapshot).toContain('加载失败: 幽灵预设') - expect(snapshot).toContain('not valid YAML') - expect(snapshot).toContain('agent.cordis.yml is missing') - expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true) - expect(await dialog.getByRole('button', { name: '复制预设: 幽灵预设' }).isDisabled()).toBe(true) - // A broken card offers no "set default" affordance at all — the aria name - // IS the broken marking, so the picking name must not exist. - expect(await dialog.getByRole('button', { name: '设为新任务默认: broken-yaml' }).count()).toBe(0) - - // The ghost's way out is the card's own delete — and the id it blocked - // is claimable again immediately afterwards. - await dialog.getByRole('button', { name: '删除: 幽灵预设' }).click() - const confirm = page.getByRole('dialog', { name: '删除该预设?' }) - await confirm.waitFor({ timeout: 10_000 }) - await confirm.getByRole('button', { name: '删除', exact: true }).click() - await confirm.waitFor({ state: 'detached', timeout: 10_000 }) - await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0) - expect(existsSync(join(userRoot, 'ghost'))).toBe(false) - - await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() - const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) - await copyDialog.waitFor({ timeout: 10_000 }) - await copyDialog.getByPlaceholder('my-agent').fill('ghost') - await copyDialog.getByRole('button', { name: '创建' }).click() - await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) - await dialog.getByRole('button', { name: '设为新任务默认: ghost' }).waitFor({ timeout: 10_000 }) - - // Leave the roster as the earlier tests shaped it. - await dialog.getByRole('button', { name: '删除: ghost' }).click() - const cleanup = page.getByRole('dialog', { name: '删除该预设?' }) - await cleanup.waitFor({ timeout: 10_000 }) - await cleanup.getByRole('button', { name: '删除', exact: true }).click() - await cleanup.waitFor({ state: 'detached', timeout: 10_000 }) - await rm(join(userRoot, 'broken-yaml'), { recursive: true, force: true }) - }, 60_000) - - it('starts a creator-mode session from the section', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-creator')) - // Without a workspace the flow only stages (there is no session to land - // in until one is connected); connect first so the gesture carries all - // the way to a composed host session. - await settingsDialog().getByRole('button', { name: '关闭' }).last().click() - await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) - await page.getByRole('button', { name: '设置', exact: true }).click() - const dialog = settingsDialog() - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).click() - - // Leaving settings is part of the gesture: the flow lands on the - // new-session screen with the self-referential preset staged, and the - // blank session the flow produces composes from it on the host. - await dialog.waitFor({ state: 'detached', timeout: 10_000 }) - await expect.poll(async () => { - const response = await scaffold.hostFetch('/api/session/list', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', rpcId: 'creator-draft-stage', method: 'session/list', - payload: { args: { _request: {} } }, - }), - }) - const body = await response.json() as { - result: { value?: { items: unknown[] } } - } - return JSON.stringify(body.result.value?.items ?? body.result) - }, { timeout: 15_000 }).toContain('"agentPreset":"cordis"') - }, 60_000) - - it('drove every surface without a page error or a stream warning', () => { - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) }) + + it('guides authoring to Creator mode instead of editing here', async () => { + onTestFailed(() => saveFailureShot(page, 'preset-roster-authoring-help')) + await page.getByRole('button', { name: '如何创建或修改预设', exact: true }).click() + const help = page.getByRole('dialog', { name: '创建和修改预设', exact: true }) + await help.waitFor() + const snapshot = await captureStableAria(page, '[role="dialog"][aria-label="创建和修改预设"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(EXPECTED, 'authoring-help.expected.md'), snapshot, mode) + expect(snapshot).toContain('让 Agent 帮我创建预设模式') + await help.getByRole('button', { name: '关闭', exact: true }).last().click() + await help.waitFor({ state: 'detached' }) + }) + + it('runs without page errors or model calls', () => { expect(tripwire.pageErrors).toEqual([]) }) }) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml deleted file mode 100644 index 3791809a7e..0000000000 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ /dev/null @@ -1,13 +0,0 @@ -# The authoring lane drives the location affordance. A real desktop open -# would pop a file manager on the machine running the tests and the -# capability itself is platform-detected (macOS yes, headless Linux CI no), -# so both native-open owners are pinned headless: `hasDocument` is false everywhere and -# `openDocument` answers the directory as text — the same branch on every -# host, and the one whose rendering a golden can hold. A patch replaces the -# row's complete config, so the shipped routing defaults ride along. -- id: session-controller - config: - nativeOpen: false -- id: settings-controller - config: - nativeOpen: false diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 791890ce7c..d9533bff3b 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -8,7 +8,7 @@ // because the host answers `agent-preset-locked` to anything else. // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -42,22 +42,15 @@ const SKILL_NAME = 'preset-catalog-demo' /** The preset whose rows resolve and then refuse to start. */ const REFUSING_ID = 'zz-refusing' -/** - * Seed a preset discovery reports healthy and the mount refuses. - * - * Every row resolves — the module is right there beside the composition — so - * health has nothing to report and the chip offers the preset like any other. - * Only starting it finds out, which is the case the chip's banner exists for. - * @param root - the lane's writable preset root. +/** Write the fixture module whose declaration fails during eager activation. + * @param root Temporary directory holding the fixture module. */ async function seedRefusingPreset(root: string): Promise { const directory = join(root, REFUSING_ID) await mkdir(directory, { recursive: true }) await writeFile(join(directory, 'refuses.mjs'), 'export const name = \'refuses\'\nexport function apply() { throw new Error(\'this row refuses to start\') }\n') - await writeFile(join(directory, 'agent.cordis.yml'), '- id: refuses\n name: ./refuses.mjs\n') - await writeFile(join(directory, 'preset.yml'), - 'name: Refusing mode\ndescription: Resolves, then refuses to start.\n') + } /** @@ -229,15 +222,14 @@ describe('web e2e: agent-preset selection', () => { let browser: Browser let page: Page let tripwire: ReturnType - let presetRoot: string + let fixtureRoot: string beforeAll(async () => { - // The shipped presets, plus one lane-owned preset that mounts and refuses: - // the chip's own failure path needs a preset the roster offers. - presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-'))) - await seedRefusingPreset(presetRoot) + // The failed declaration remains in the registry, outside the selectable options. + fixtureRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-refusing-'))) + await seedRefusingPreset(fixtureRoot) scaffold = await launchWebScaffold({ - agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' }, + agentPresets: { default: 'standard', definitions: [{ id: REFUSING_ID, name: 'Refusing mode', description: 'Refuses to start.', plugins: [{ name: pathToFileURL(join(fixtureRoot, REFUSING_ID, 'refuses.mjs')).href }] }] }, }) // A resumed session runs what it was created with; seeding one that // records `minimal` is what makes the header label a claim about the @@ -255,7 +247,7 @@ describe('web e2e: agent-preset selection', () => { afterAll(async () => { await browser?.close() await scaffold?.close() - await rm(presetRoot, { recursive: true, force: true }) + await rm(fixtureRoot, { recursive: true, force: true }) }) it('starts with mode selection shown on the Standard default', async () => { @@ -304,20 +296,13 @@ describe('web e2e: agent-preset selection', () => { expect(roster.presets.find(preset => preset.isDefault)?.id).toBe('standard') }) - it('says why a switch was refused instead of letting the chip revert in silence', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-preset-refused')) + it('omits eagerly failed presets from selection and retains their diagnostics', async () => { await page.getByRole('button', { name: 'Minimal mode' }).click() - await page.getByRole('menuitem', { name: /Refusing mode/ }).click() - - // Health cleared every row, so nothing on the settings page says this - // preset is unusable — the banner is where the host's reason lands, and - // without it the chip just snaps back to the preset it already ran. - const banner = page.getByRole('alert').filter({ hasText: 'Refusing mode' }) - await banner.waitFor({ timeout: 15_000 }) - expect(await banner.textContent()).toContain('this row refuses to start') - await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') - await page.getByRole('button', { name: 'Minimal mode' }).waitFor({ timeout: 10_000 }) - }, 60_000) + await page.getByRole('menu').waitFor() + expect(await page.getByRole('menuitem', { name: /Refusing mode/ }).count()).toBe(0) + await page.keyboard.press('Escape') + expect((await scaffold.ctx.agentPresets.resolve(REFUSING_ID)).broken).toContain('this row refuses to start') + }) it('re-reads the slash catalog through the composition the switch installed', async () => { // Continues 'applies the staged pick': the chip has already applied `minimal` to diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 97202b950e..b7ab90a8e2 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -52,21 +52,16 @@ interface ComposedEntry { } interface BootComposition { + bundlePatchPaths(packageDir: string, bundle: { patch: string | string[] }): string[] loadOverlayPatches(binName: string, file: string): unknown[] composeEntries(layers: readonly unknown[][]): ComposedEntry[] } const REPO_ROOT = process.cwd() -const BUNDLE_LAYERS = [ - { - manifest: join(REPO_ROOT, 'packages/bundle/base/package.json'), - patch: join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml'), - }, - { - manifest: join(REPO_ROOT, 'packages/bundle/web-app/package.json'), - patch: join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml'), - }, -] as const +const BUNDLE_LAYERS = ['packages/bundle/base', 'packages/bundle/web-app'].map(dir => ({ + dir: join(REPO_ROOT, dir), + manifest: join(REPO_ROOT, dir, 'package.json'), +})) const bundleResolvers = BUNDLE_LAYERS.map(layer => createRequire(layer.manifest)) const webBundleResolver = bundleResolvers[1] if (webBundleResolver === undefined) throw new Error('assembled boot: web bundle resolver missing') @@ -97,8 +92,10 @@ const comboReference = (ids: readonly string[], rev: string): string => /** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */ function loadAssembledPlugins(): readonly AssembledPlugin[] { - const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer => - appBoot.loadOverlayPatches('assembled boot', layer.patch))) + const entries = appBoot.composeEntries(BUNDLE_LAYERS.map((layer) => { + const declared = (JSON.parse(readFileSync(layer.manifest, 'utf8')) as { dsh: { bundle: { patch: string | string[] } } }).dsh.bundle + return appBoot.bundlePatchPaths(layer.dir, declared).flatMap(patch => appBoot.loadOverlayPatches('assembled boot', patch)) + })) const plugins = new Map() for (const entry of entries) { if (entry.disabled === true || typeof entry.name !== 'string') continue diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 8d7502264b..b67b8c9837 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -153,9 +153,8 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass 'session/modelCatalog': structuredClone(fixture.modelCatalog), 'agentPresets/list': structuredClone(fixture.agentPresets), 'commands/list': structuredClone(fixture.commands), - 'settings/canOpenAgentPresetDirectory': ok(true), 'settings/openSettingsDocument': ok({ opened: true }), - 'settings/openAgentPresetDirectory': 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/expected/agent-preset-authoring/authoring-help.expected.md b/apps/web/tests/expected/agent-preset-authoring/authoring-help.expected.md new file mode 100644 index 0000000000..f264bf7a11 --- /dev/null +++ b/apps/web/tests/expected/agent-preset-authoring/authoring-help.expected.md @@ -0,0 +1,6 @@ +- dialog "创建和修改预设": + - heading "创建和修改预设" [level=2] + - button "关闭" + - paragraph: 预设由已安装的 bundle 声明,本页只负责选择。要创建或修改预设,请用「创造模式」开始一个任务:Agent 会编写一个声明或覆盖该预设的 bundle,并安装到当前 profile。 + - button "关闭" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md deleted file mode 100644 index f3d67b4e69..0000000000 --- a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md +++ /dev/null @@ -1,13 +0,0 @@ -- dialog "复制预设 · 复制自 极简模式": - - heading "复制预设 · 复制自 极简模式" [level=2] - - button "关闭" - - paragraph: 复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。 - - text: 标识符 - - textbox "标识符": - - /placeholder: my-agent - - text: 名称 - - textbox "名称": - - /placeholder: 在模式列表中显示的名称,不填则使用标识符 - - alert: 请填写标识符。 - - button "取消" - - button "创建" [disabled] diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md deleted file mode 100644 index 20455df885..0000000000 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ /dev/null @@ -1,66 +0,0 @@ -- dialog "设置": - - navigation: - - text: 设置 - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "Agent 预设" [level=2] - - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - - text: 新任务可选择模式 Beta - - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 - - switch "新任务可选择模式" [checked] - - heading "内置" [level=3] - - list: - - listitem: - - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 新任务默认 - - code: standard - - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - - 'button "模式说明: 标准模式"': 模式说明 - - 'button "如何使用: 标准模式"': 如何使用 - - 'button "查看配置: 标准模式"': 查看配置 - - 'button "复制预设: 标准模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: PTC 模式"': - - text: PTC 模式 内置 - - code: ptc - - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - - 'button "模式说明: PTC 模式"': 模式说明 - - 'button "如何使用: PTC 模式"': 如何使用 - - 'button "查看配置: PTC 模式"': 查看配置 - - 'button "复制预设: PTC 模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 极简模式"': - - text: 极简模式 内置 - - code: minimal - - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - - 'button "模式说明: 极简模式"': 模式说明 - - 'button "如何使用: 极简模式"': 如何使用 - - 'button "查看配置: 极简模式"': 查看配置 - - 'button "复制预设: 极简模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 创造模式"': - - text: 创造模式 内置 - - code: cordis - - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - - 'button "模式说明: 创造模式"': 模式说明 - - 'button "如何使用: 创造模式"': 如何使用 - - 'button "查看配置: 创造模式"': 查看配置 - - 'button "复制预设: 创造模式"': 复制预设 - - heading "自定义" [level=3] - - list: - - listitem: - - 'button "设为新任务默认: 我的模式"': - - text: 我的模式 自定义 - - code: my-agent - - text: 仅提供持久 shell 的单工具编码 Agent。 - - 'button "查看路径: 我的模式"': 查看路径 - - 'button "复制预设: 我的模式"': 复制预设 - - 'button "删除: 我的模式"': 删除 - - paragraph: - - text: 预设文件: - - code: {{presetRoot}}/my-agent - - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md deleted file mode 100644 index a7434c0336..0000000000 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ /dev/null @@ -1,73 +0,0 @@ -- dialog "设置": - - navigation: - - text: 设置 - - button "通用设置" - - button "模型" - - button "内置插件" - - button "Agent 预设" - - button "打开配置文件" - - button "关闭" - - heading "Agent 预设" [level=2] - - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - - text: 新任务可选择模式 Beta - - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 - - switch "新任务可选择模式" [checked] - - heading "内置" [level=3] - - list: - - listitem: - - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 新任务默认 - - code: standard - - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - - 'button "模式说明: 标准模式"': 模式说明 - - 'button "如何使用: 标准模式"': 如何使用 - - 'button "查看配置: 标准模式"': 查看配置 - - 'button "复制预设: 标准模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: PTC 模式"': - - text: PTC 模式 内置 - - code: ptc - - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - - 'button "模式说明: PTC 模式"': 模式说明 - - 'button "如何使用: PTC 模式"': 如何使用 - - 'button "查看配置: PTC 模式"': 查看配置 - - 'button "复制预设: PTC 模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 极简模式"': - - text: 极简模式 内置 - - code: minimal - - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - - 'button "模式说明: 极简模式"': 模式说明 - - 'button "如何使用: 极简模式"': 如何使用 - - 'button "查看配置: 极简模式"': 查看配置 - - 'button "复制预设: 极简模式"': 复制预设 - - listitem: - - 'button "设为新任务默认: 创造模式"': - - text: 创造模式 内置 - - code: cordis - - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - - 'button "模式说明: 创造模式"': 模式说明 - - 'button "如何使用: 创造模式"': 如何使用 - - 'button "查看配置: 创造模式"': 查看配置 - - 'button "复制预设: 创造模式"': 复制预设 - - heading "自定义" [level=3] - - list: - - listitem: - - 'button "加载失败: broken-yaml" [disabled]': - - text: broken-yaml 加载失败 自定义 - - code: broken-yaml - - text: 暂无描述。 - - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - - 'button "查看路径: broken-yaml"': 查看路径 - - 'button "复制预设: broken-yaml" [disabled]': 预设加载失败,不能复制 - - 'button "删除: broken-yaml"': 删除 - - listitem: - - 'button "加载失败: 幽灵预设" [disabled]': - - text: 幽灵预设 加载失败 自定义 - - code: ghost - - text: composition 已被手动删除。 - - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - - 'button "查看路径: 幽灵预设"': 查看路径 - - 'button "复制预设: 幽灵预设" [disabled]': 预设加载失败,不能复制 - - 'button "删除: 幽灵预设"': 删除 - - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 44b226efc0..4eb1c8791d 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -8,6 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] + - button "如何创建或修改预设" - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - text: 新任务可选择模式 Beta - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 @@ -21,8 +22,6 @@ - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - 'button "模式说明: 标准模式"': 模式说明 - 'button "如何使用: 标准模式"': 如何使用 - - 'button "查看配置: 标准模式"': 查看配置 - - 'button "复制预设: 标准模式"': 复制预设 - listitem: - 'button "设为新任务默认: PTC 模式"': - text: PTC 模式 内置 @@ -30,8 +29,6 @@ - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - 'button "模式说明: PTC 模式"': 模式说明 - 'button "如何使用: PTC 模式"': 如何使用 - - 'button "查看配置: PTC 模式"': 查看配置 - - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 @@ -39,8 +36,6 @@ - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 - - 'button "查看配置: 极简模式"': 查看配置 - - 'button "复制预设: 极简模式"': 复制预设 - listitem: - 'button "设为新任务默认: 创造模式"': - text: 创造模式 内置 @@ -48,7 +43,3 @@ - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - 'button "模式说明: 创造模式"': 模式说明 - 'button "如何使用: 创造模式"': 如何使用 - - 'button "查看配置: 创造模式"': 查看配置 - - 'button "复制预设: 创造模式"': 复制预设 - - heading "自定义" [level=3] - - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 76b76a3463..2f715b99b1 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -3,4 +3,3 @@ - menuitem "PTC mode Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results." - menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance." - menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode." - - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/expected/new-session-refused/notice.expected.md b/apps/web/tests/expected/new-session-refused/notice.expected.md index 52e7072f0f..ee4467be62 100644 --- a/apps/web/tests/expected/new-session-refused/notice.expected.md +++ b/apps/web/tests/expected/new-session-refused/notice.expected.md @@ -1 +1 @@ -- alert: "新建会话失败:agent-preset/invalid: agent-presets: preset \"renamed-plugin\" failed to mount: row \"ghost\" names a plugin that cannot be resolved: @deepseek-ai/dsh-no-such-plugin" +- alert: "新建会话失败:agent-preset/invalid: ghost (@deepseek-ai/dsh-no-such-plugin): never started" diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json index 9d0d916a99..e375c6deb5 100644 --- a/apps/web/tests/fixtures/assembled-remote.fixture.json +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -182,7 +182,6 @@ "isDefault": false } ], - "authorable": true, "modeSelectionEnabled": true } }, diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 1c41897422..f7d72cddc5 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -8,7 +8,7 @@ import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-system-prompt' import { assertFixtureInventory, diff --git a/apps/web/tests/new-session-refused.e2e.ts b/apps/web/tests/new-session-refused.e2e.ts index 80dcb8e990..9f3a50a41a 100644 --- a/apps/web/tests/new-session-refused.e2e.ts +++ b/apps/web/tests/new-session-refused.e2e.ts @@ -1,7 +1,4 @@ /** An explicit New Session the Host refuses reports through the Workspace notice in the shipped Web composition. */ -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium } from 'playwright' import { describe, expect, it, onTestFinished } from 'vitest' @@ -13,28 +10,19 @@ const MODE = webSnapshotMode() /** The default preset of this lane: its one row names a package the Host cannot resolve, as a preset copied before a plugin rename does. */ const PRESET_ID = 'renamed-plugin' -/** - * Seed a user preset root holding one preset the roster reads as broken. - * - * Discovery reports the unresolvable row before any mount, so the refusal - * carries no temp path and the notice text is stable across runs. - * @param root - the lane-owned preset root. - */ -async function seedBrokenPreset(root: string): Promise { - const directory = join(root, PRESET_ID) - await mkdir(directory, { recursive: true }) - await writeFile(join(directory, 'agent.cordis.yml'), '- id: ghost\n name: \'@deepseek-ai/dsh-no-such-plugin\'\n') - await writeFile(join(directory, 'preset.yml'), 'name: Renamed plugin\ndescription: Names a plugin that no longer resolves.\n') +/** The default preset of this lane as a declaration: its one row names a package the Host cannot resolve. */ +const BROKEN_PRESET = { + id: PRESET_ID, + name: 'Renamed plugin', + description: 'Names a plugin that no longer resolves.', + plugins: [{ id: 'ghost', name: '@deepseek-ai/dsh-no-such-plugin' }], } describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => { it('shows the Host refusal as a notice and leaves the Workspace without a Session', async () => { - const presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-broken-preset-'))) - onTestFinished(() => rm(presetRoot, { recursive: true, force: true })) - await seedBrokenPreset(presetRoot) const scaffold = await launchWebScaffold({ firstUse: true, - agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: PRESET_ID }, + agentPresets: { default: PRESET_ID, definitions: [BROKEN_PRESET] }, }) onTestFinished(() => scaffold.close()) const browser = await chromium.launch() diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 36d237857a..1c525a93f0 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -56,7 +56,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con scaffold = await launchWebScaffold({ openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), - agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, + agentPresets: { default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) // File associations belong to the desktop rather than the recorded Session. diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index 37fdd06073..3ca55f2f85 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { generationLogPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { scanZstdFrames } from '../../../packages/session/session-persistence-jsonl/src/zstd.ts' -import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-agent-preset-registry' import type {} from '@deepseek-ai/dsh-api-session-controller' import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' diff --git a/apps/web/tests/ptc-escalation.e2e.ts b/apps/web/tests/ptc-escalation.e2e.ts index 9f71da0890..3f3a74a65a 100644 --- a/apps/web/tests/ptc-escalation.e2e.ts +++ b/apps/web/tests/ptc-escalation.e2e.ts @@ -32,7 +32,7 @@ describe('web e2e: PTC program sandbox escalation', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - agentPresets: { roots: [], default: 'ptc' }, + agentPresets: { default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 9410cace37..40cbd21764 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -32,7 +32,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - agentPresets: { roots: [], default: 'ptc' }, + agentPresets: { default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index db5e4c66bf..2cfc21e2c9 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -28,10 +28,14 @@ it('ships install metadata with the built web application', async () => { }) }) -it('ships a favicon that switches to a light mark under dark color scheme', async () => { - const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') - // The light fill must live inside the dark-scheme media query, so the icon - // stays black in light mode and only turns white under a dark scheme. - expect(favicon).toMatch(/@media \(prefers-color-scheme: dark\)\s*{\s*path\s*{[^}]*fill:\s*#fff/i) - expect(favicon).toContain('fill="#000"') +it('ships fixed-color favicons selected by document media queries', async () => { + const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8') + expect(index).toContain('') + expect(index).toContain('') + const light = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') + const dark = await readFile(join(DIST_ROOT, 'favicon-dark.svg'), 'utf8') + expect(light).not.toContain('