diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index 5b233e16db..6578c0b2a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.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-06-11-dev-invariants-over-deep-readonly.md -2026-06-11-dev-invariants-over-deep-readonly.md: 9adb741db1e26e3a9f750fed60524351159cfeaa -2026-06-11-dev-invariants-over-deep-readonly.zh.md: d352eca985b6594ec5d10c9b73c2ecc18ea458b5 +2026-06-11-dev-invariants-over-deep-readonly.md: 0784960397f4d198e136e284ef9232b4ed3e2c2c +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 7183813130560bf82426c8719570b668ba86b7e5 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 9adb741db1..0784960397 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -22,7 +22,7 @@ Responsibility is split between an always-on storage boundary and optional devel `Session` accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references. -The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` and `eventAt()` avoid array materialization when a caller needs only the current length or one event. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. +The accepted event and all of its descendants are deep-frozen before publication. `append()` returns that owned frozen event, and `session/event` observers and `eventAt(seq)` receive the same record. `snapshotEvents(fromSeq?, toSeqExclusive?)` returns a frozen array snapshot; a previously returned array does not grow after a later append. `seq` reads the current length without materializing an array. Synchronous historical readers are deprecated under the [event-read policy](2026-09-09-deprecate-synchronous-session-event-reads.md). Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds. This guarantee belongs in `Session`, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered. @@ -53,7 +53,7 @@ Detaching `deriveMessages()` would protect the most common request path but leav ## Consequences - Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it. -- `snapshotEvents()` exposes stable immutable snapshots instead of the private growing array; `seq` and `eventAt()` serve scalar reads without copying that array. +- Existing `snapshotEvents()` and `eventAt()` callers retain immutable read results while their migration is deferred; `seq` reads the log length without copying the array. - Request-side mutation cannot reach stored history through derived messages. - Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability. - `dsh-invariants` configures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package. diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index d352eca985..7183813130 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -22,7 +22,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 `Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。 -被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。调用方只需要当前长度或单个事件时,`seq` 和 `eventAt()` 不会物化数组。种子记录在构造成功前经过相同的验证、快照与冻结边界。 +被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件,`session/event` 观察者和 `eventAt(seq)` 接收同一记录。`snapshotEvents(fromSeq?, toSeqExclusive?)` 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。`seq` 无需物化数组即可读取当前长度。同步历史读取方法按[事件读取策略](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)弃用。种子记录在构造成功前经过相同的验证、快照与冻结边界。 此保证属于 `Session` 而非可选监听器,因为每种组合都依赖可信的历史。无论是否注册了开发支持插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。 @@ -53,7 +53,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ## 后果 - 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。 -- `snapshotEvents()` 暴露稳定的不可变快照,而非持续增长的私有数组;`seq` 和 `eventAt()` 为标量读取提供无需复制数组的路径。 +- 现有 `snapshotEvents()` 和 `eventAt()` 调用方在暂缓迁移期间仍获得不可变的读取结果;`seq` 无需复制数组即可读取日志长度。 - 请求侧的修改无法通过派生消息触及已存储的历史。 - 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。 - `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 611e38e8ba..57dba7f52b 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.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-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 35bb7c6fabc85ae6f93bdbb67e13910eea627ca3 -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 53d69cf45c02f6534334561b626d2c2ae6087c05 +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 8f5542cb09930e62fd1e26960166fbdd6d3f6745 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 348ae36cf6549d412adab1d3cdb5cc03f0badd01 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 35bb7c6fab..8f5542cb09 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -26,7 +26,7 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. Sections may set `interpolate: false` to preserve generated documents literally; `tools:sdk` does so because tool descriptions and schemas may document their own `{{…}}` syntax. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. @@ -47,7 +47,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - **The loop composes an identity line itself** — hardcodes model-facing prose in the one package that must stay thin ("plugins, not loop changes"), and outside the section pipeline it would be a second composition path. (The identity DOES ship as a code literal — but as an ordinary section registered by `dsh-system-prompt`, whose `system-prompt/assemble` waterfall remains the escape valve for a deployment that must drop it.) - **Inject the model name via the `agent/request` waterfall** — prompt text would be composed in two places and the earlier rendered persona could disagree with the final routed header. The request plugin that owns late routing must also own any earlier prompt claim about that model. - **Hand-write the model name in each persona** — duplicates the `model:` key one line above and silently lies after a config edit; the exact disease this decision cures. -- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. +- **Lenient interpolation (leave unknown refs verbatim, or substitute empty)** — a typo ships `{{modle}}` (or a hole) to the model and nobody notices until transcript review. Leaving only unknown names unchanged would still substitute registered names inside tool documentation. - **Per-instance subagent wording in config** — returns model-facing prose to every deployment × instance, reviving the hand-written-guidance-in-leaf-YAML drift. **Keying wording off the provider NAME** — `providerName` is itself config, so a renamed provider silently gets the wrong words. - **Resolving the provider at `apply` time (a load-order requirement)** and **section-only subagent wording (lazily resolved at assemble)** — the alternatives to the provider-lifecycle events; both rejected in [the provider-lifecycle-events Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md). @@ -60,7 +60,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - The tui-agent prompt renders identity, persona with the interpolated model, then fs/shell/web guidance through one assembly path. - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. -- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. +- In interpolated sections, unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. ## Consequences @@ -69,4 +69,4 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect - `{{model}}` reflects `AgentOptions.model` at assembly time. A plugin that switches models in the `agent/request` waterfall makes the prompt's claim stale for that step, and one that SUPPLIES the model there (options.model unset — the loop's documented fallback) leaves the variable valueless at render, failing a `{{model}}` persona before the waterfall runs. Both have the same remedy, and it is the ownership rule itself: the plugin that owns the late-bound model fact states it early on the `system-prompt/assemble` waterfall (`assembly.variables['model'] = …`) — one owner, both statements; a loop test pins the supply path end-to-end. Accepted. - While a bound provider is absent (not yet activated, unloaded, mid-HMR-reload), the subagent tool does not exist and a model request in that window simply lacks it. That is the honest state — the alternative was a registered tool whose description or execution could not be trusted. - Strictness means a persona can fail a turn at render (e.g. `{{cwd}}` on a cwd-less session). The failure is contained — the turn ends `error`, the loop survives — and it is an authoring error we WANT loud. -- No escape syntax for a literal `{{name}}` in prompt prose yet; add one if a real prompt ever needs it. +- Inline escapes remain unsupported in interpolated text; literal sections need no escaping. PTC unit tests cover both modes and runtime languages, and the recorded `ptc-turn` scenario preserves tool-template examples in the model-visible prompt. diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 53d69cf45c..348ae36cf6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -26,7 +26,7 @@ Status: implemented ### 提示词变量 -插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。 +插件通过 `ctx.systemPrompt.variable(name, provider)` 注册 `{{name}}` 值。组装过程将它们解析到 waterfall 可见的变量映射中。渲染阶段拒绝以下情况:引用未知的自有属性、已注册的提供方返回 `undefined`、格式错误的完整引用、以及仍包含闭合 `}}` 的不平衡引用;孤立的未匹配 `{{` 保留为行文,替换后的值不会被重新扫描。注册阶段拒绝无效或重复的变量名,section 名称也必须唯一。段可设置 `interpolate: false` 来原样保留生成的文档;`tools:sdk` 使用此设置,因为工具描述和 schema 可能会介绍自身的 `{{…}}` 语法。 `dsh-agent-loop` 注册两个内置变量,均为上下文 agent 的纯投影:`model`(= `options.model`)和 `cwd`(= `session.header.cwd`)。示例 persona 写 `powered by the {{model}} model`——模型名称只在 `model:` 配置键中声明一次。`{{cwd}}` 仅在 ACP 示例中演示:每个 ACP 会话携带客户端的 cwd,而配置预创建的 stdio agent 没有 cwd(在那里声称 `{{cwd}}` 的 persona 会导致该轮次失败——这是有意为之)。变量留在 loop 插件上(不同于下面的 section):它们是本循环驱动的 agent 的运行时事实,替换循环自行提供自己的变量。 @@ -47,7 +47,7 @@ Status: implemented - **循环自行组合一行 identity 文本**:在必须保持精简的那个包(「用插件,不改循环」)中硬编码面向模型的行文,且在 section 流水线之外构成第二条组合路径。(identity 确实以代码字面量交付——但作为 `dsh-system-prompt` 注册的普通 section,其 `system-prompt/assemble` waterfall 仍是部署需要移除它时的逃生阀。) - **通过 `agent/request` waterfall 注入模型名称**:提示词文本会在两处组合,更早渲染的 persona 也可能与最终已路由 header 不一致。拥有延迟路由的请求插件还必须拥有该模型在提示词中更早出现的声明。 - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。 -- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 +- **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。仅保留未知名称仍会替换工具文档中的已注册名称。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 - **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](../../archived/architecture/2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 @@ -60,7 +60,7 @@ Status: implemented - tui-agent 的提示词通过一条组装路径依次渲染 identity、带插值模型名的 persona,然后是 fs/shell/web 指导。 - fork 和 fresh subagent 的描述反映提供方是否继承已完成的对话轮次;工具随提供方生命周期变化而出现、消失和重新措辞。 -- 未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 +- 在启用插值的段中,未知、无值、格式错误或不平衡的变量引用会指明 section 名称并抛出异常;重复的 section、变量和工具注册同样抛出异常。 - 快照回放与提示词无关:它按轮次和步骤索引已记录的分片流,不比较发出的请求。 ## 后果 @@ -69,4 +69,4 @@ Status: implemented - `{{model}}` 在组装时反映 `AgentOptions.model`。如果一个插件在 `agent/request` waterfall 中切换模型,提示词对该步骤的声明就会过时;如果一个插件在那里提供模型(options.model 未设置——循环文档中记载的回退路径),变量在渲染时无值,包含 `{{model}}` 的 persona 会在 waterfall 运行前失败。两者的补救方式相同,就是归属规则本身:拥有延迟绑定模型事实的插件在 `system-prompt/assemble` waterfall 上提前声明它(`assembly.variables['model'] = …`)——一个归属方,两处声明;一个循环测试端到端固定了 supply 路径。已接受。 - 当一个已绑定的提供方不存在时(尚未激活、已卸载、HMR(热模块替换)重载中),subagent 工具不存在,该窗口内的模型请求中不会包含它。这是诚实的状态——替代方案是注册一个 description 或执行都不可信的工具。 - 严格性意味着 persona 可能在渲染时导致轮次失败(例如在无 cwd 的会话上使用 `{{cwd}}`)。失败是受控的——该轮次以 `error` 结束,循环存活——且这是一个我们希望明确暴露的撰写错误。 -- 目前没有在提示词行文中转义字面 `{{name}}` 的语法;如果真实提示词确实需要,再行添加。 +- 插值文本仍不支持行内转义;字面文本段无需转义。PTC 单元测试覆盖两种模式和运行时语言,录制的 `ptc-turn` 场景在模型可见的提示词中保留工具模板示例。 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 d03d20b03b..f7987d80d7 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: 7792e24a5869be6b7bae7787a6a481f3075ba740 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 857bfaec80da962fac0403f2239e0a5e71954194 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 8c55c142137f0ab24869bd57ffed3835b7b0516a +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 52ca2e13901d9469e2f9663936acb766a934e8f7 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 7792e24a58..8c55c14213 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 @@ -44,7 +44,7 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. 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 857bfaec80..52ca2e1390 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 @@ -44,7 +44,7 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 生产部署允许未使用的工作区补丁,因为开发工具可能不属于运行时闭包;闭包内包的补丁应用失败仍会中止部署。工作区安装继续严格校验未使用的补丁。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 82efeeb49e..f0da51ef73 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -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-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 21bad78792c6b5aad48b51f454f6c08c0400ad72 -2026-07-23-client-plugin-loading-model.zh.md: 2758f28f3bd34131ece3bed74152fbfe0b36174e +2026-07-23-client-plugin-loading-model.md: c5576b148c5991dd498d3aed605e3c2e3395774b +2026-07-23-client-plugin-loading-model.zh.md: c7d6982c2680995bd4698ddbff052a7708f69997 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 21bad78792..c5576b148c 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -52,7 +52,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows receive an opaque process nonce plus sequence without hashing their artifacts; startup combo revisions hash the combined script inputs plus indexed map, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 2758f28f3b..c7d6982c26 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -52,7 +52,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;启动 combo revision 对合并脚本输入及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index b8ff3f88c2..83d23518ec 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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-24-single-harness-home-resolver.md -2026-07-24-single-harness-home-resolver.md: 02578674b4547bae54b2edce99c6c4ef53748588 -2026-07-24-single-harness-home-resolver.zh.md: 5764be3f85901196cd2a2db77d1fc3838668163f +2026-07-24-single-harness-home-resolver.md: 1191c52bfa222b807e9aa301b250d2efcc8b0953 +2026-07-24-single-harness-home-resolver.zh.md: cefa124b7cb46bab92157af34576f59d3053da9f diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md index 02578674b4..1191c52bfa 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -23,6 +23,8 @@ explicit configured path > $DSH_HOME > ~/.dsh An empty or whitespace-only `$DSH_HOME` is treated as unset; otherwise `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces agent-instructions's bespoke default-vs-`$DSH_HOME` check. +`dshCachePath(...segments)` derives paths below the resolved home's `cache` directory. An initial `{ dshHome }` option preserves a provider's explicit home override. It resolves paths without creating directories; callers own directory creation. `attachment-local` uses this helper for regenerable request-image variants while retaining durable attachment objects in their versioned storage tree, so clearing the cache cannot remove Session attachments. Existing request-image cache entries are left in place and are not read or copied; a cache miss regenerates the variant from its durable attachment. + `@deepseek-ai/dsh-home` is deleted. Home-owning providers and boot packages import `resolveDshHome` from `dsh-home-paths`; composition bundles contain only the resolved configuration rows. `dsh-telemetry` and its separate home policy are absent under the [SDK project toolchain removal](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md), leaving this resolver as the sole home policy. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 5764be3f85..cefa124b7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -23,6 +23,8 @@ explicit configured path > $DSH_HOME > ~/.dsh 空或仅含空白的 `$DSH_HOME` 被当作未设置处理;否则,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 agent-instructions 中自定义的「默认值 vs `$DSH_HOME`」判断。 +`dshCachePath(...segments)` 在解析出的主目录下的 `cache` 目录中派生路径。首个 `{ dshHome }` 选项保留提供方显式配置的主目录覆盖值。它只解析路径,不创建目录;目录创建由调用方负责。`attachment-local` 将此函数用于可重新生成的请求图片版本,持久附件对象仍保留在其带版本的存储树中,因此清空缓存不会删除 Session 附件。已有请求图片缓存条目保留在原处,不再读取或复制;缓存未命中时从持久附件重新生成请求版本。 + `@deepseek-ai/dsh-home` 被删除。拥有 home 配置的提供方与 boot 包从 `dsh-home-paths` 导入 `resolveDshHome`;组合包只包含解析后的配置行。 `dsh-telemetry` 及其独立 home 策略已随 [SDK 项目工具链移除](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md)一并消失,因此该解析器是唯一的 home 策略。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 43a487556f..4951fbfe77 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: e7c0781e1504bce12a8b0d2197bd47b37d3e873a -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 9a0affa397151a3994c5731bebda4b45a4139b6c +2026-07-24-web-config-tree-boot-and-transport-layering.md: c8a1db3cd7bd715d8912b22a0b41a9ef660c1316 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 41fcf65b06829199c203faa806bd17a310b68bcf diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index e7c0781e15..c8a1db3cd7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../../archived/simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. +**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../../archived/simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. `auditStartupEntries` reports failed imports, reads failed fibers for original activation errors, and lists services that leave a fiber `PENDING`. The [startup policy](2026-09-09-consumer-owned-startup-strictness.md) makes optional failures warnings and required failures fatal. Reported rejection reasons stay marked through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations. **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 9a0affa397..41fcf65b06 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../../archived/simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的始终启用的 bundle 行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 +**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做主干 bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../../archived/simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的始终启用的 bundle 行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。`auditStartupEntries` 报告 import 失败、读取失败 fiber 的原始激活错误,并列出让 fiber 停在 `PENDING` 的服务。[启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md)将 optional failure 作为 warning,将 required failure 视为致命错误。已报告的 rejection 原因会保持标记至一个进程级检查点,使 `installFailLoud` 合并 Loader 的重复通知,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。 **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 9695955b54..362b1c40a8 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/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: 8af48979b49f08c8e3ac945f648acbb615757a98 -2026-08-03-per-session-agent-presets.zh.md: 2889487e989d093c162848c3c972d46fece3726d +2026-08-03-per-session-agent-presets.md: c2c9f670df480662804e086fbf150c773d8f4fc8 +2026-08-03-per-session-agent-presets.zh.md: 9a4760be9e4a9d1d2de0a7f8d2d3bce755c04480 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 8af48979b4..c2c9f670df 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -33,7 +33,7 @@ The `agent-presets` user-settings namespace carries `modeSelectionEnabled` and ` **The effective default is read per resolution, never snapshotted.** A cached value would need a `watch` subscription and a reload path to stay honest, and the resolved scope already re-reads a hot-reloaded document. The Host setting itself applies when an unnamed session is resolved afterwards. An explicit Web Settings action additionally routes its accepted effective default through the existing blank-session selection path only when the captured session id is still current and blank; it never recomposes a running session or rewrites that session's history. The session log enforces the same invariant from the other side — the header records the id a session was CREATED with and an `agent-preset/selected` event records any later blank-session switch, so a reader resolves the pair (`resolveSessionPreset`) and never the header alone: a resume rebuilds the composition its history was produced under rather than the deployment default at resume time, a cold transcript's presenters resolve in that composition's layer, and the gateway rejects an attempt to adopt a live session under a preset other than the one it currently runs. A snapshot would make the two disagree at exactly the moment the setting changes. -**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `assertEntriesActivated` cannot see it. The mount audits its own rows instead, reading the tree through an `Include` subclass that publishes it. +**A directly-plugged subtree is invisible to the boot audit.** It never links itself to an `Entry`, so it is absent from `ctx.loader.entries()` and `auditStartupEntries` cannot see it. The mount reads its own rows through an `Include` subclass that publishes the tree and requires every enabled row to activate, independently of the [application startup policy](2026-09-09-consumer-owned-startup-strictness.md). **A preset can only name a group because the app registers one.** Sharing a realm across rows is a `cordis:group` row, and a preset living outside this workspace — the authored ones under the Harness home, which is the point — cannot resolve `@cordisjs/plugin-group` by name: Node's upward `node_modules` walk never reaches the harness from there. `boot()` therefore registers `cordis:group` beside `cordis:include` as a loader builtin, so both load through the ambient module pipeline rather than through the included tree's own specifier resolution. Without it the `isolate` vocabulary above is expressible one row at a time only, and a provider could never be grouped with its consumers. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 2889487e98..9a4760be9e 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -33,7 +33,7 @@ Status: implemented **有效默认值在每次解析时读取,绝不保存快照。** 缓存下来就需要一个 `watch` 订阅和一条重载路径才能保持诚实,而解析后的 scope 本来就会重读热重载过的文档。Host 设置本身会在此后解析未指名会话时生效。Web Settings 中的明确操作还会把已接受的有效默认值送入既有的空白会话选择链路,但只在操作前捕获的会话 id 仍是当前空白会话时对齐;它绝不会重新组装运行中的会话,也不会改写该会话的历史。session 日志从另一侧执行同一条不变量——header 记录会话**创建时**的 id,此后空白期的任何切换由 `agent-preset/selected` 事件记录,因此读取方解析的是两者之和(`resolveSessionPreset`)、绝不单看 header:恢复重建的是其历史所产出的那份组装而不是恢复时的部署默认值,冷读记录的 presenter 在那份组装的层里解析,网关也会拒绝把一个活着的会话收编到它当前运行的 preset 以外的 preset 之下。快照会让两者恰好在设置改变的那一刻各说各话。 -**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`assertEntriesActivated` 也看不到它。改由挂载过程自行校验各行,通过一个会公开自身 tree 的 `Include` 子类读取。 +**直接挂载的子树对启动审计不可见。** 它不会把自己关联到 `Entry`,因此不在 `ctx.loader.entries()` 中,`auditStartupEntries` 也看不到它。挂载过程通过一个会公开自身 tree 的 `Include` 子类读取各行,并要求每个启用行都激活,不受[应用启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md)影响。 **preset 能写出 group,是因为 app 注册了它。** 跨行共享 realm 就是一个 `cordis:group` 行,而住在本工作区之外的 preset——也就是 Harness home 下由人或 agent 创作的那些,正是这套设计的目的——无法按名字解析 `@cordisjs/plugin-group`:Node 向上查找 `node_modules` 的路径从那里永远走不到 harness。因此 `boot()` 把 `cordis:group` 与 `cordis:include` 并排注册为 loader builtin,两者都经由环境模块管线加载,而不依赖被包含树自身的说明符解析。没有它,上文那套 `isolate` 词汇就只能一行一行地表达,提供方也永远无法与它的消费方归入同一组。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 8aa7824f1f..9537e05d16 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: 6d22777c8913911dbb1d89c09de9e891636873c8 -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 5108492ba6f029847735f570aa77c2cb505f981c +2026-08-25-electron-desktop-packaging-and-updates.md: 4a4dfe8910905b3c35fbdfdcaedd34a556b532f3 +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 69877127578ae4d61643653403b736dbb5e7b1e6 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index 6d22777c89..4a4dfe8910 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -4,19 +4,21 @@ Status: implemented English | [中文](2026-08-25-electron-desktop-packaging-and-updates.zh.md) +Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). + ## Problem DeepSeek Harness needs an Electron desktop application that reuses the Web UI, works without system Node.js or pnpm, installs dsh and desktop plugins through an application-bundled pnpm, and updates the complete desktop release through one user-facing flow. The desktop application and an npm-installed dsh share the `.dsh` data root, but they may have different dsh and plugin versions. They must share supported product data without sharing executable packages, lockfiles, `node_modules`, plugin activation, or package-manager configuration. -The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its pnpm-installed dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. +The current GUI protocol binds the Web client and backend release. Independently versioning the Electron artifact and its bundled dsh would create unqualified shell, client, backend, and plugin combinations and make update availability ambiguous. ## Decision Ship a small Electron shell with a bundled upstream Node.js executable and pinned pnpm. Electron starts the private Desktop Host package as an isolated child process; that package composes the installed dsh backend and matching client graph. Fetch metadata and bounded raw request and response chunks travel over two versioned framed byte pipes, Node IPC is reserved for readiness, fatal failure, and shutdown, and Electron serves validated assets through `dsh-app://`; it opens no listening port. Each frame carries a fixed marker, type, monotonic stream id, payload length, and validated payload. Serialized writers honor pipe drain, readers pause globally when a request or response stream applies backpressure, cancellation closes the matching stream, and late response frames for a retired stream stay inert. The Connection plugin provides its carrier-neutral RPC and Fetch registries without requiring `webServer`, while Client Modules provides the exact advertised combo-bundle responses to the shell-owned carrier; Web compositions attach their optional HTTP routes for both. The renderer keeps the same Fetch, RPC, and Remote-stream formats, while the child carrier avoids Base64 expansion and V8 serialization compatibility between Electron and the bundled upstream Node.js. Electron closes its request-pipe writer after sending shutdown, releasing an in-flight Windows pipe read before it waits for child exit. This follows the Electron reservation in the [GUI layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). -Electron owns the reserved profile at `.dsh/profiles/desktop`. Its exact `@deepseek-ai/dsh` dependency supplies the backend and matching Web UI, while the matching private `@deepseek-ai/dsh-desktop-host` dependency supplies only the Electron child-process entry and composition overlay. The dsh release, private Host, and their first-party dependency closures use local npm tarballs packed from the same source build; the profile manifest lists every core package as a local `file:` dependency, and `pnpm-workspace.yaml` repeats the mapping as overrides. The Host remains outside the public CLI package and is never published to npm. Desktop plugins are additional registry npm dependencies and ordered `dsh.profile.bundles` entries in the same profile, and resolve from its one `node_modules`. +Electron owns the reserved profile at `.dsh/profiles/desktop`. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) owns core resource storage, external plugin dependencies, shared package links, and profile reconciliation. The private Desktop Host remains outside the public CLI package and is never published to npm. One Desktop release number identifies the Electron artifact and its exact `@deepseek-ai/dsh` and `@deepseek-ai/dsh-desktop-host` dependencies. A release cannot select a different core version at build or runtime. Updating dsh therefore requires a new Electron release even when shell code is unchanged. @@ -26,9 +28,9 @@ The browser Web UI, dsh backend, existing `dsh plugin` CLI, user npm, and user p | Owner | Responsibility | |---|---| -| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination, rollback | +| Electron shell | Window and child lifecycle, framed byte pipes, lifecycle IPC, custom protocol, reserved desktop profile, plugin GUI, update coordination | | Bundled Node.js and pnpm | Execute dsh and install exact desktop-project dependencies without consulting user `PATH` or pnpm state | -| Desktop profile | One dependency graph, ordered bundle list, and `node_modules` for the desktop dsh package and desktop plugins | +| Desktop profile | External plugin dependencies, ordered enabled bundles, and shared links defined by the bundled-runtime decision | | Private Desktop Host package | Electron-only child-process entry and composition overlay installed with dsh but excluded from the public CLI package and npm publication | | Installed dsh package | Backend, matching Web UI, boot manifest, client bundles, and product behavior | | Shared `.dsh` owners | Sessions, settings, credentials, workspaces, and storage, guarded by their existing locks and format versions | @@ -41,10 +43,6 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb ```text ~/.dsh/ desktop/ - staging//profile/ - rollback/profile/ - pending.json - lock pnpm/ store/ cache/ @@ -54,52 +52,54 @@ The renderer uses `nodeIntegration: false`, `contextIsolation: true`, and `sandb desktop/ package.json pnpm-lock.yaml + lock + desktop-packages-pending pnpm-workspace.yaml - desktop-release.json - desktop-packages.json - desktop-packages/ + desktop-runtime-state.json node_modules/ sessions/ storages/ ``` -`.dsh/profiles/desktop` is the only active desktop profile. Its package manifest records the built-in and installed plugin bundle order; Electron alone mutates its dependencies, lockfile, and `node_modules`. Production startup rejects a bundle resolved outside this profile, including the CLI-maintained `.dsh/profiles/node_modules` fallback. Package content installed for the desktop profile uses `.dsh/desktop/pnpm/store`. +`.dsh/profiles/desktop` is the only active desktop profile. Its executable package ownership and allowed resolution directories follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Plugin package content uses `.dsh/desktop/pnpm/store`. ## Installation and resolution -The installer never mutates the active profile in place. It copies profile metadata into a transaction staging directory and applies an exact dependency change with the bundled pnpm. Before testing staging, Electron stops the active backend; it starts and stops the staged backend alone, then restores the active backend before activation, so two Desktop backends never concurrently share `.dsh` state. Activation stops the backend again, persists each next `pending.json` phase before its corresponding filesystem move, moves the active profile to `rollback/profile`, moves staging into `.dsh/profiles/desktop`, and restarts. Recovery combines the write-ahead phase with the actual active, rollback, and staging directories so either write-to-move interruption retains or restores a complete profile. +Desktop stops the Host before modifying its profile in place. Package failures retain partial changes for explicit repair; profile mutation and package-retry ownership follow the [in-place decision](2026-09-09-desktop-in-place-profile.md). -The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing store or staging transaction; after that worker exits, the stale PID can be recovered. +The process-lifetime Electron lock is the authoritative Desktop owner. The package transaction lock is depth defense and records the process that can still mutate package state: Electron between package operations and the spawned pnpm PID while pnpm runs. The owner change is truncated, written, and synchronized through the already-open exclusive lock file. If Electron terminates during pnpm execution, a later process observes the live worker and refuses to start a competing package transaction; after that worker exits, the stale PID can be recovered. -The packaged seed is an offline installation kit, not an executable dsh tree. It contains the release identity, initial desktop-project manifest, a descriptor and immutable tarballs for the union of the first-party package closures rooted at dsh and the private Desktop Host, lockfile, integrity inventory, and required store subset. Each `mac-arm64`, `mac-x64`, and `win-x64` build owns its packed packages, runtime, package set, seed, pnpm preparation state, unpacked application, update metadata, and final artifacts under `.desktop-build/targets/`; only the immutable, checksum-verified Node.js download cache is shared. The release build requires the Electron package, root dsh package, and private Host package to have the same version, creates final npm tarballs from the official source build, locally packs the private Host, selects the reachable dsh, Host, and vendored packages plus the Landlock entry, and verifies that the Host tarball contains `lib/index.js` and `config/desktop.cordis.patch.yml`. The Host `files` manifest contains only that runtime entry and overlay, and the package is never published to npm. Public package tarballs remain the official `pnpm pack` results governed by each package's publication manifest; Desktop does not remove published declarations or otherwise create a second package-content policy. The seed manifest lists every selected package as a local direct dependency, automatic peer installation is disabled, and the workspace file overrides every selected first-party name to its local tarball. The target Node.js executes bundled pnpm, so pnpm's operating-system and CPU selection makes the materialized dependency graph and seed target-specific. Bundled pnpm disables its global virtual store, materializes external production dependencies from npm without lifecycle scripts, deletes `node_modules` and every temporary pnpm cache, config, and state directory, then performs a clean offline installation from the final store alone and checks the private Host entry and overlay. The build rejects any lockfile that resolves one of the local first-party names by registry version. Inventory generation follows removal of that second `node_modules` tree and temporary pnpm project registrations. Requiring both Host files before copying the package set and after offline installation prevents a release whose process entry loads but cannot compose its required overlay from reaching application signing. - -The seed stores pnpm content in 16 deterministic uncompressed tar shards selected by normalized store path. Apple notarization inspects Mach-O code inside those archives, so macOS seed preparation stages every referenced Mach-O content-addressed object and runs at most four independent Developer ID signers concurrently with a secure timestamp and hardened runtime. A signer failure is observed only after every active signer exits and leaves the original CAS objects and package index unchanged. After all signers succeed, preparation writes each object at its new SHA-512 path and transactionally rewrites every base and side-effects file reference in pnpm's MessagePack SQLite index. A second offline installation proves that pnpm resolves the rewritten store; preparation then shards it, extracts the final archives, and verifies every embedded signature. Package paths and non-native bytes remain unchanged, and the seed retains bundled architecture variants because removing files would create a Desktop-specific package file set. Seed integrity covers the shard manifest and every archive before extraction. Startup validates archive paths, entry types, uniqueness, and counts, extracts every shard into a unique Desktop-owned staging directory, replaces matching immutable store files, and transactionally merges each pnpm store version's SQLite `package_index` into `.dsh/desktop/pnpm/store`. Seed records replace matching keys while records downloaded for Desktop plugins remain. An interrupted file merge may leave valid immutable cache content, but each SQLite merge is atomic, and profile installation and activation still require pnpm integrity and the complete health check. - -Startup requires the packaged release identity to equal Electron's application version, then compares `.dsh/profiles/desktop/desktop-release.json` plus the installed dsh and Desktop Host packages with that release before launching the backend. It installs the new seed manifest and lockfile with `pnpm install --offline --frozen-lockfile --trust-lockfile` in staging. After Electron replacement, it restores every plugin bundle recorded in the active profile at its exact installed version through one offline pnpm add from the existing desktop store and metadata cache. The complete graph must pass the same health check before activation. - -The plugin GUI performs registry npm-package operations equivalent to `pnpm add --save-exact`, `pnpm remove `, and exact-version update in staging. Every mutation retains the local core-package descriptor, tarballs, dsh and Desktop Host dependencies, and complete override map. Electron validates the installed package manifest and updates the profile's dependency and ordered bundle entries; no renderer request can choose the registry, install directory, lifecycle policy, or arbitrary pnpm flags. - -The backend and Loader use `.dsh/profiles/desktop/package.json` as their profile manifest and npm resolution anchor. The shared profile loader composes its ordered bundle entries, then the private Desktop Host applies its packaged overlay. The Host, dsh, Cordis, desktop plugins, plugin dependencies, and peer dependencies resolve through the ordinary pnpm `node_modules` graph. A desktop plugin contributing `dsh.client` code enters the boot manifest only after the complete profile passes health checking. +Core materialization, first launch, plugin installation, and shared-module resolution follow the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). The actual Host composes enabled desktop plugins contributing `dsh.client` code when it starts. ## Updates and recovery Electron update uses one `electron-updater` release stream and signed `electron-builder` artifacts. Its version is the Desktop release version; there is no independent dsh manifest, compatibility range, or dsh-only update operation. A foreground install waits for an in-flight background check rather than reusing its result as an install result. The update dialog downloads and installs the Electron artifact, then restarts into the new release. -Before the new release opens a window, startup reconciles dsh from its packaged seed while retaining installed desktop plugins. The health check covers dependency resolution, native modules, shell API compatibility, backend startup and shutdown, Web assets, and the client boot graph. An incompatible plugin blocks activation and leaves the previous project available for rollback. Startup fails visibly rather than launching a shell and dsh version that do not match. +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns the local loading page, direct Host startup, and recovery in the main window. Profile reconciliation follows the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm staging transaction remain separate operations. +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. ## Security and release policy -Core dsh and the private Desktop Host come only from integrity-recorded local npm tarballs inside the signed Electron release; pnpm overrides prevent transitive core packages from falling back to a registry. Store archives are integrity-checked and fully validated in an isolated extraction directory before their files can enter writable package state. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, user-only directory permissions, redacted diagnostics, and health checking are required before activation. +Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation accepts registry package specs allowed by desktop policy but never raw pnpm commands. Exact versions, lockfile integrity, a reviewed `allowBuilds` set, and user-only directory permissions are required before activation. -Electron artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Seed preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. Electron-builder then notarizes and staples the application and signs the DMG. The DMG artifact-completion hook separately notarizes and staples every DMG before requiring the configured identity, a valid ticket, and Gatekeeper acceptance; the upload event runs only after that hook succeeds. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +Electron release artifacts are signed; macOS artifacts are notarized. Release automation must supply the application ID, macOS Developer ID qualifier, expected Team ID, and one complete notarytool credential strategy through explicit environment variables. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. -Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and seed-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. +The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. -Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, seed, or active project. +Windows release packaging supplies the public EV leaf certificate named by `DSH_DESKTOP_WINDOWS_CER_FILE` to the configured SafeNet-compatible SignTool through `/f` and identifies its matching private key through the required `DSH_DESKTOP_WINDOWS_KEY_CONTAINER`. The certificate file remains outside source control, and the private key remains on the USB token. The electron-builder hook passes each artifact to the CRLF `windows-sign.cmd`, whose single SignTool invocation uses the SafeNet `/kc "[{{PIN}}]=container"` value and CSP, a SHA-256 file digest, and a DigiCert SHA-256 RFC 3161 timestamp. The hook never substitutes another SignTool and never retries a failed request. Package orchestration withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation children and passes only the certificate path, SignTool path, key container, and PIN into electron-builder. The signer supplies only validated signing fields in an otherwise scrubbed CMD environment; the CMD disables delayed expansion, clears those fields before SignTool starts, and preserves the PIN only in the required SignTool command line. Every surfaced diagnostic replaces the PIN, and only the dedicated build account and administrators may inspect the runner. The signer signs electron-builder's temporary NSIS bootstrap before enterprise Code Integrity evaluates that executable and clears a generated executable's certificate-table entry only when it points beyond the file before applying the final signature. Packaging fails before producing unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable. The custom protocol serves the installed frontend distribution plus client files named by the active module graph and rejects traversal or access outside those roots. The plugin installer API is available only to the Electron-owned management GUI and is absent from the browser application and backend RPC. -The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the seed store subset. Architecture-specific builds must report actual component-level size deltas. +Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled 7-Zip 24.09 encoder automatically selects ARM64 filters for ARM64 PE files, but the NSIS decoder from `nsis-resources-3.4.1` omits those entries during extraction. A native extraction probe with the actual NSIS plugin loses both `node-pty` ARM64 binaries under automatic filtering and restores both byte-for-byte with BCJ. Keeping a compatible filter preserves dependency contents and runtime integrity instead of removing architecture-specific files or weakening verification. + +Local Windows installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without an EV token while preventing local test output from qualifying for release upload. + +NSIS extracts into its private `7z-out` directory before copying files into the application directory. Its default exit cleanup can overlap the backend's file reads after Finish launches the application. The [installer hook](../../../../apps/desktop/scripts/installer.nsh) removes only that extraction directory during `customInstall`, before interactive and silent launch branches. It preserves package archives, plugin DLLs, rollback directories, registers, and error status; the [native cleanup smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) checks these constraints. Moving cleanup into installation does not remove filesystem work, so total installation time and Finish-to-window time require separate measurements. + +Direct `Nsis7z::Extract` into the application directory is not enabled. A native [locked-file probe](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi) leaves an old locked file beside a new asset while reporting no error; the staged `CopyFiles` operation sets the error flag for the same failed replacement. An unchanged 737,557,488-byte payload on Windows took 172.625 seconds through extraction, copying, and cleanup versus 28.031 seconds through direct extraction, but that single sample per path does not justify losing failure detection. The clocks exclude registry changes, old-version removal, and post-extraction verification; system caches were not cleared. Desktop-specific payload filtering reduces the copied file set while retaining the installer's replacement-error handling. This handling is not a promise of complete installation rollback. + +Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the Node.js binary, pnpm entry, dsh resources, or active project. + +The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compressed and 120–165 MB installed before the dsh production tree. Architecture-specific builds must report actual component-level size deltas. ## Implementation @@ -107,10 +107,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr |---|---| | Shell | `apps/desktop` owns Electron windows, restricted preloads, the custom protocol, child lifecycle, project transactions, the plugin GUI, update coordination, and electron-builder configuration. | | Installed runtime | Private `@deepseek-ai/dsh-desktop-host` boots the portless desktop composition from the active project and streams API and asset responses over validated framed byte pipes. | -| Package state | The release seed and every later mutation run through bundled Node.js and pnpm with desktop-owned store, config, cache, state, and home paths; core packages resolve from release tarballs while plugins resolve from the fixed npm registry. | -| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native seed object after final archive extraction, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | +| Package state | Bundled Node.js executes immutable core resources; bundled pnpm modifies only the external plugin graph in the Desktop profile. | +| Qualification | macOS packaging requires the configured company identity and notary credentials, verifies every native runtime file before inventory generation, verifies the completed application signature, and requires notarization plus Gatekeeper acceptance for both the application and DMG. Windows packaging requires the configured public certificate, SafeNet private-key container, Token Password, and SignTool, and verifies every produced signature. Update hosting, previous-version installed-artifact tests, and platform GUI recordings remain release-environment gates. | -`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, seed installation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. +`dev:desktop` builds the current workspace, projects the built CLI and private Desktop Host packages plus their dependency links into a disposable project, uses an isolated Harness home, opens the Main, Renderer, and Host debuggers, and starts unpackaged Electron without preparing release resources. Package mutation is disabled in this mode because its linked dependency graph is not a pnpm-installed desktop project. Fixed macOS arm64, macOS x64, and Windows x64 package commands pass one target through runtime preparation, dsh preparation, and electron-builder; each also has an unpacked-directory variant for release-path verification before installer generation. ## Alternatives considered @@ -122,11 +122,11 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr **Reuse the existing CLI or browser plugin installer.** That crosses the desktop authorization and release scope and can use the user's package-manager state. Desktop package mutation remains exclusively Electron-owned. -**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other's dependency graph, Cordis version, plugin version, or native module. The desktop profile therefore owns a complete `node_modules` and rejects bundle resolution through the CLI profile fallback. +**Let the desktop profile use CLI-managed packages or plugins.** Either product could change the other’s dependency graph, Cordis version, plugin version, or native module. Desktop rejects package resolution through the CLI profile fallback. -**Install dsh and plugins into separate desktop projects.** This creates a second resolution anchor and peer-dependency fallback. One ordinary npm project already provides the required installation and resolution model. +**Separate core and plugin resolution without shared package ownership.** That permits duplicate host modules and uncontrolled peer fallback. The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supplies explicit links and dependency validation for the separate resource and profile directories. -**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of seed space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. +**Remove non-target Mach-O files from registry packages.** Architecture pruning saves a small amount of runtime space, but packages can deliberately ship several architecture variants and callers can observe their installed file set. Signing every shipped Mach-O object satisfies notarization without inventing a Desktop-specific package layout. **Export the Windows EV private key in a PFX file.** The externally supplied public leaf certificate lets SignTool construct the signature while `/csp` and `/kc` locate the hardware key. The EV private key remains non-exportable on the token. @@ -136,15 +136,15 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr ## Consequences -- A clean offline machine with no system Node.js or pnpm installs the seed into `.dsh/profiles/desktop` and starts a working dsh session. -- The signed application inventories a fixed small set of seed store shards instead of every pnpm cache file; every Mach-O object inside the macOS shards has the release Developer ID, secure timestamp, and hardened runtime, every Windows artifact has the configured hardware-backed EV signature, and the installed private store retains the ordinary pnpm layout. -- `.dsh/profiles/desktop/node_modules` contains and resolves the desktop dsh package and every GUI-installed desktop plugin. +- A clean offline machine without system Node.js or pnpm starts bundled dsh without installing core dependencies. +- The signed application inventories final runtime files; every macOS native file has the release Developer ID, secure timestamp, and hardened runtime, and every Windows artifact has the configured hardware-backed EV signature. +- `.dsh/profiles/desktop/node_modules` resolves shared host links and every GUI-installed desktop plugin. - Every desktop pnpm operation uses the bundled executable and `.dsh/desktop/pnpm/store`; none reads user `PATH`, config, store, or profile `node_modules`. - The Electron-only GUI installs, removes, and updates ordinary npm plugin packages without exposing raw pnpm arguments. - The backend and browser application cannot mutate desktop packages. - npm/CLI dsh and Electron never resolve or install plugins from each other's `node_modules`. -- The active backend and Web UI report the same dsh version and a compatible shell API before the product window opens. -- Failed installation, health checking, or update leaves the current profile usable or restores `rollback/profile` after restart. +- The active backend and Web UI report the same dsh version and a compatible shell API before the product UI loads. +- Package or Host failures retain partial profile changes and expose recovery controls; no automatic profile rollback is promised. - One Desktop version binds Electron and dsh; every dsh update arrives through one Electron update dialog and one user-visible restart. - Shared `.dsh` data rejects incompatible readers before migration or mutation. - No loopback listener is opened, and the sandboxed renderer cannot access arbitrary filesystem or Electron APIs. @@ -157,10 +157,10 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr | Decision | Recommendation | |---|---| -| First launch | Bundle an offline seed store subset and install it through pnpm | -| Desktop profile | One Electron-owned reserved profile containing exact dsh and plugin dependencies | +| First launch | Check bundled release metadata and create profile links without installing core dependencies | +| Desktop profile | One Electron-owned reserved profile for external plugins and shared package links | | Plugin management | Electron-only GUI and package service; no CLI, backend, or browser installation path | -| Activation | Staging project, complete health check, journaled directory replacement, one rollback copy | +| Activation | In-place package changes followed by actual Host startup | | Initial platforms | macOS arm64/x64 and Windows x64; Linux has no supported release target | | Update behavior | Background check, explicit confirmation before differential download and restart, startup dsh reconciliation | @@ -168,10 +168,14 @@ The bundled upstream Node.js and pnpm are expected to add about 35–50 MB compr Plugin lifecycle scripts execute third-party code. The allowed registry, package policy, exact versions, integrity, `allowBuilds`, and diagnostics require security review before GUI installation ships. -Updating the bound dsh can invalidate plugin peer dependencies or native modules. pnpm resolution and full-project health checking must reject the staged project before replacing the active one. +Updating the bound dsh can invalidate plugin peer dependencies or native modules. Reconciliation validates changed dependencies and rebuilds native packages; failures require explicit repair through the recovery UI. An npm-installed dsh and desktop dsh may have different versions while sharing durable data. Each shared owner must enforce its format version and process lock before reading, migrating, or writing. -Directory replacement differs across operating systems and can be interrupted. The activation journal and installed-artifact fault tests must prove recovery at every filesystem move. +Interrupted package operations retain a pending marker. Installed-artifact tests must verify that a later launch retries the locked installation and approved native builds. Code signing, notarization, and update hosting require production release infrastructure. Repository tests alone cannot complete that qualification. + +## Related proposals + +The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) supersedes this note’s offline runtime preparation and single-project package ownership. Release identity, signing, portless transport, process ownership, and Electron-only package authorization remain owned here. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 5108492ba6..6987712757 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -4,19 +4,21 @@ Status: implemented [English](2026-08-25-electron-desktop-packaging-and-updates.md) | 中文 +profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 + ## 问题 DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。 桌面应用与通过 npm 安装的 dsh 共享 `.dsh` 数据根目录,但两者可能使用不同的 dsh 与插件版本。它们必须共享受支持的产品数据,同时不得共享可执行包、lockfile、`node_modules`、插件激活状态或包管理器配置。 -当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 pnpm 安装的 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 +当前 GUI 协议绑定 Web 客户端与后端版本。Electron 产物与其中通过 内置 dsh 如果独立定版本,就会产生未经验证的壳、客户端、后端与插件组合,也无法明确判断更新是否可用。 ## 决策 交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。 -Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepseek-ai/dsh` 依赖提供后端与匹配的 Web UI,匹配的私有 `@deepseek-ai/dsh-desktop-host` 依赖则只提供 Electron 子进程入口与组合 overlay。dsh 发布、私有 Host 及其第一方依赖闭包使用同一次源码构建生成的本地 npm tarball;profile manifest 把每个核心包列为本地 `file:` 依赖,`pnpm-workspace.yaml` 再通过 overrides 重复该映射。Host 不进入公共 CLI 包,也不会发布到 npm。桌面插件既是同一 profile 中来自 registry 的其他 npm 依赖,也是有序的 `dsh.profile.bundles` 条目,并从该 profile 唯一的 `node_modules` 解析。 +Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。 一个 Desktop 发布号同时标识 Electron 产物及其精确的 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-desktop-host` 依赖。发布不能在构建或运行时选择不同的核心版本。因此,即使壳代码没有变化,更新 dsh 也必须产生新的 Electron 发布。 @@ -26,9 +28,9 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse | Owner | 职责 | |---|---| -| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调、回滚 | +| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调 | | 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 | -| Desktop profile | 为桌面 dsh 包与桌面插件提供一个依赖图、有序 bundle 列表和一个 `node_modules` | +| Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 | | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay | | 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 | | 共享 `.dsh` owner | 会话、设置、凭据、工作区和存储,由其现有锁与格式版本保护 | @@ -41,10 +43,6 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse ```text ~/.dsh/ desktop/ - staging//profile/ - rollback/profile/ - pending.json - lock pnpm/ store/ cache/ @@ -54,50 +52,52 @@ Electron 拥有保留 profile `.dsh/profiles/desktop`。其中精确的 `@deepse desktop/ package.json pnpm-lock.yaml + lock + desktop-packages-pending pnpm-workspace.yaml - desktop-release.json - desktop-packages.json - desktop-packages/ + desktop-runtime-state.json node_modules/ sessions/ storages/ ``` -`.dsh/profiles/desktop` 是唯一活跃的 desktop profile。其 package manifest 记录内置与已安装插件 bundle 的顺序;只有 Electron 可以修改它的依赖、lockfile 和 `node_modules`。生产启动会拒绝解析到该 profile 之外的 bundle,包括 CLI 维护的 `.dsh/profiles/node_modules` fallback。desktop profile 安装的所有包内容都使用 `.dsh/desktop/pnpm/store`。 +`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。插件包内容使用 `.dsh/desktop/pnpm/store`。 ## 安装与解析 -安装器绝不原地修改活跃 profile。它把 profile 元数据复制到事务暂存目录,并使用内置 pnpm 应用精确依赖变更。测试 staging 前,Electron 会停止活跃后端;它单独启动并停止 staging 后端,再在激活前恢复活跃后端,因此两个 Desktop 后端绝不会并发共享 `.dsh` 状态。激活过程再次停止后端,在对应目录移动前先持久化 `pending.json` 的每个下一阶段,把活跃 profile 移到 `rollback/profile`,把暂存 profile 移到 `.dsh/profiles/desktop`,然后重启。恢复过程会结合预写阶段与真实的 active、rollback 和 staging 目录,因此任一个写入与移动间隙中断后仍会保留或恢复一个完整 profile。 +Desktop 在直接修改 profile 前停止 Host。包操作失败后保留部分修改,供显式修复;profile 修改和包重试的职责遵循[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)。 -进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 store 或 staging 事务;该 worker 退出后,陈旧 PID 才可以恢复。 +进程生命周期 Electron 锁是 Desktop 的权威 owner。包事务锁用于纵深防御,并记录仍能修改包状态的进程:包操作之间记录 Electron,pnpm 运行期间记录已生成的 pnpm PID。Owner 变更通过已经打开的排他锁文件完成截断、写入与同步。如果 Electron 在 pnpm 执行期间终止,后续进程会发现仍存活的 worker,并拒绝启动并发的 包事务;该 worker 退出后,陈旧 PID 才可以恢复。 -打包 seed 是离线安装包,而不是可执行 dsh 目录。它包含发布身份、初始桌面项目 manifest、分别以 dsh 和私有 Desktop Host 为根的第一方包闭包之并集的描述文件及不可变 tarball、lockfile、完整性清单和所需 store 子集。每个 `mac-arm64`、`mac-x64` 和 `win-x64` 构建都在 `.desktop-build/targets/` 下持有自己的打包输入、运行时、包集合、seed、pnpm 准备状态、未打包应用、更新元数据和最终产物;只有不可变且经过校验和验证的 Node.js 下载缓存会被共享。发布构建要求 Electron 包、根 dsh 包与私有 Host 包使用相同版本,从正式源码构建生成最终 npm tarball,在本地打包私有 Host,选择可达的 dsh、Host 与 vendored 包以及 Landlock 入口,并验证 Host tarball 中包含 `lib/index.js` 与 `config/desktop.cordis.patch.yml`。Host 的 `files` manifest 只包含该运行入口与 overlay,并且该包不会发布到 npm。公共包 tarball 仍是由各包发布 manifest 控制的正式 `pnpm pack` 结果;Desktop 不删除已发布的声明文件,也不建立第二套包内容策略。seed manifest 把每个选中的包列为本地直接依赖,关闭 peer dependency 自动安装,workspace 文件再把每个选中的第一方包 override 到对应本地 tarball。目标 Node.js 执行内置 pnpm,因此 pnpm 的操作系统和 CPU 选择会使物化的依赖图与 seed 成为目标专用内容。内置 pnpm 关闭全局 virtual store,在禁用生命周期脚本的情况下从 npm 物化外部生产依赖,删除 `node_modules` 以及所有临时 pnpm cache、config 和 state 目录,然后只使用最终 store 执行一次干净的离线安装,并检查私有 Host 的入口与 overlay。构建会拒绝任何通过 registry 版本解析本地第一方包名的 lockfile。生成清单前会删除第二次生成的 `node_modules` 和临时 pnpm 项目注册。在复制 package set 前与离线安装后都要求这两个 Host 文件,可防止进程入口能够加载、却无法组合所需 overlay 的发布进入应用签名阶段。 - -种子根据规范化 store 路径,把 pnpm 内容放入 16 个确定性的未压缩 tar 分片。Apple 公证会检查这些归档内的 Mach-O 代码,因此 macOS seed 会 staging 每个被引用的内容寻址 Mach-O 对象,最多并发四个独立的 Developer ID 签名进程,并带上安全时间戳与 hardened runtime。任一签名失败后,准备过程会等待已启动的签名进程全部退出,原始 CAS 对象与包索引保持不变。所有签名成功后,准备过程把每个对象写到新的 SHA-512 路径,并以事务方式重写 pnpm MessagePack SQLite 索引内全部基础文件和 side-effects 文件引用。第二次离线安装证明 pnpm 可以解析重写后的 store;准备过程随后完成分片、解包最终归档并验证每个内嵌签名。包路径和非原生字节保持不变;种子保留包内附带的架构变体,因为删除文件会创建 Desktop 专属的包文件集。种子完整性覆盖分片 manifest 和解包前的每个归档。启动时验证归档路径、条目类型、唯一性和数量,把所有分片解包到唯一且由 Desktop 拥有的 staging 目录,替换匹配的不可变 store 文件,并以事务方式把各 pnpm store 版本的 SQLite `package_index` 合并进 `.dsh/desktop/pnpm/store`。Seed 记录替换匹配的键,为 Desktop 插件下载的记录继续保留。中断的文件合并可能留下有效的不可变缓存内容,但每次 SQLite 合并都是原子的,profile 安装与激活仍必须通过 pnpm 完整性与完整健康检查。 - -启动过程先要求安装包内的发布身份等于 Electron 应用版本,再在启动后端前比较 `.dsh/profiles/desktop/desktop-release.json`、已安装 dsh 包、已安装 Desktop Host 包与该发布版本。它在 staging 中通过 `pnpm install --offline --frozen-lockfile --trust-lockfile` 安装新的 seed manifest 与 lockfile。Electron 替换后,启动过程再通过一次离线 pnpm add,从桌面端现有 store 与元数据缓存恢复活跃 profile 记录的每个插件 bundle 精确版本。完整依赖图必须通过同一套健康检查才能激活。 - -插件 GUI 执行等价于 `pnpm add --save-exact`、`pnpm remove ` 和精确版本更新的 registry npm 包操作。每次修改都保留本地核心包描述文件、tarball、dsh 与 Desktop Host 依赖和完整 override 映射。Electron 验证已安装包 manifest,并更新 profile 的依赖与有序 bundle 条目;任何渲染进程请求都不能选择 registry、安装目录、生命周期策略或任意 pnpm flag。 - -后端与 Loader 把 `.dsh/profiles/desktop/package.json` 作为 profile manifest 和 npm 解析锚点。公共 profile loader 先组合其中的有序 bundle 条目,再由私有 Desktop Host 应用其打包的 overlay。Host、dsh、Cordis、桌面插件、插件依赖和 peer dependency 均通过普通 pnpm `node_modules` 图解析。贡献 `dsh.client` 代码的桌面插件只有在完整 profile 通过健康检查后才进入启动 manifest。 +核心物化、首次启动、插件安装和共享模块解析遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。实际 Host 启动时会组合已启用且提供 `dsh.client` 代码的桌面插件。 ## 更新与恢复 Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-builder` 产物。该版本就是 Desktop 发布版本;不存在独立 dsh manifest、兼容范围或仅更新 dsh 的操作。前台安装会等待正在进行的后台检查,而不会把检查结果复用成安装结果。更新弹窗下载并安装 Electron 产物,然后重启进入新发布。 -新发布在打开窗口前从安装包种子校准 dsh,同时保留已安装桌面插件。健康检查覆盖依赖解析、原生模块、壳 API 兼容性、后端启停、Web 资源和客户端启动图。不兼容插件会阻止激活,并保留上一个项目用于回滚。启动过程会明确失败,而不会运行版本不匹配的壳与 dsh。 +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)负责本地加载页、直接启动 Host 和主窗口恢复。profile 协调遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。 -`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm staging 事务仍是两个独立操作。 +`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 ## 安全与发布策略 -核心 dsh 与私有 Desktop Host 只能来自签名 Electron 发布内经过完整性记录的本地 npm tarball;pnpm overrides 防止传递核心包回退到 registry。Store 归档经过完整性检查,并在隔离的解包目录中完成全部验证,归档文件随后才能进入可写包状态。插件安装接受桌面策略允许的 registry 包 spec,但绝不接受原始 pnpm 命令。激活前必须具备精确版本、lockfile 完整性、经过评审的 `allowBuilds` 集合、仅限用户的目录权限、遮盖后的诊断和健康检查。 +核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限。 -Electron 产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。Seed 准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。Electron-builder 随后公证应用并钉票、签署 DMG。DMG 的 artifact-completion hook 会单独公证每个 DMG 并钉票,再要求其使用配置的身份、具备有效票据并通过 Gatekeeper;只有该 hook 成功,上传事件才会执行。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 -Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 seed 准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 +[固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 -打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、seed 或活跃项目。 +Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。 + +Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。 + +本地 Windows 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有 EV Token 时诊断安装问题,也能防止本地测试产物通过发布上传校验。 + +NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录。Finish 启动应用后,默认退出清理可能与后端的文件读取重叠。[安装器 hook](../../../../apps/desktop/scripts/installer.nsh) 在 `customInstall` 阶段仅删除该解压目录,早于交互和静默启动分支。它保留包归档、插件 DLL、回滚目录、寄存器和错误状态;[原生清理 smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) 检查这些约束。把清理移入安装阶段并不会减少文件系统工作,因此必须分别测量安装总耗时与点击 Finish 到窗口出现的耗时。 + +安装器不开启直接向应用目录执行 `Nsis7z::Extract`。原生[文件占用探针](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi)会在未报错的情况下留下被占用的旧文件和新资源;暂存后执行的 `CopyFiles` 在相同替换失败时会设置错误标志。Windows 上同一份 737,557,488 字节载荷经过解压、复制和清理耗时 172.625 秒,直接解压耗时 28.031 秒,但每条路径的单次样本不足以支持放弃失败检测。计时不包括注册表修改、旧版删除及解压后的验证,也没有清空系统缓存。桌面专用载荷过滤减少需要复制的文件,同时保留安装器的替换错误处理。这种处理并不承诺完整的安装回滚。 + +打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。 在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。 @@ -107,10 +107,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool |---|---| | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 | | 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 | -| 包状态 | 发布种子和后续每次修改都通过内置 Node.js 与 pnpm 执行,并使用桌面端拥有的 store、config、cache、state 和 home 路径;核心包从发布 tarball 解析,插件从固定 npm registry 解析。 | -| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在解包最终归档后验证每个原生 seed 对象,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | +| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 | +| 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 | -`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、seed 安装和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 +`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。 ## 考虑过的替代方案 @@ -122,11 +122,11 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool **复用现有 CLI 或浏览器插件安装器。** 这会跨越桌面授权与发布 scope,并可能使用用户的包管理器状态。桌面包修改完全由 Electron 拥有。 -**让 desktop profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一方的依赖图、Cordis 版本、插件版本或原生模块。因此 desktop profile 持有完整 `node_modules`,并拒绝通过 CLI profile fallback 解析 bundle。 +**让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。 -**把 dsh 与插件安装到不同桌面项目。** 这会产生第二解析锚点和 peer dependency 回退。一个普通 npm 项目已经提供所需安装与解析模型。 +**分离核心与插件解析,却不明确共享包归属。** 这会允许宿主模块重复以及不可控的 peer 回退。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)为分离的资源和 profile 目录提供明确链接及依赖验证。 -**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 seed 空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 +**从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 运行时空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。 **把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。 @@ -136,15 +136,15 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool ## 结果 -- 没有系统 Node.js 或 pnpm 的干净离线机器把种子安装进 `.dsh/profiles/desktop`,并启动可工作的 dsh 会话。 -- 已签名应用记录固定少量的 seed store 分片,而不是记录每个 pnpm 缓存文件;macOS 分片内每个 Mach-O 对象都带有发布 Developer ID、安全时间戳与 hardened runtime,每个 Windows 产物都带有配置的硬件支持 EV 签名,安装后的私有 store 仍保持普通 pnpm 布局。 -- `.dsh/profiles/desktop/node_modules` 包含并解析桌面 dsh 包和每个 GUI 安装的桌面插件。 +- 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。 +- 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。 +- `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。 - 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。 - 后端与浏览器应用不能修改桌面包。 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。 -- 在产品窗口打开前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 -- 安装、健康检查或更新失败后,当前 profile 仍然可用,或在重启后恢复 `rollback/profile`。 +- 在产品 UI 加载前,活跃后端与 Web UI 报告相同 dsh 版本和兼容壳 API。 +- 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。 - 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。 @@ -157,10 +157,10 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool | 决策 | 建议 | |---|---| -| 首次启动 | 打包离线种子 store 子集,并通过 pnpm 安装 | -| Desktop profile | 一个包含精确 dsh 与插件依赖、由 Electron 持有的保留 profile | +| 首次启动 | 检查内置发布元数据并创建 profile 链接,不安装核心依赖 | +| 桌面 profile | 一个由 Electron 拥有的保留 profile,保存外部插件和共享包链接 | | 插件管理 | Electron-only GUI 与包服务;没有 CLI、后端或浏览器安装路径 | -| 激活 | 暂存项目、完整健康检查、记录式目录替换和一个回滚副本 | +| 激活 | 直接修改包后启动实际 Host | | 初始平台 | macOS arm64/x64 与 Windows x64;Linux 尚无受支持的发布目标 | | 更新行为 | 后台检查,差分下载与重启前显式确认,启动时校准 dsh | @@ -168,10 +168,14 @@ Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。 -更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。pnpm 解析与完整项目健康检查必须在替换活跃项目前拒绝暂存项目。 +更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。校准会验证变化的依赖并重建原生包;失败后需要通过恢复 UI 显式修复。 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。 -不同操作系统的目录替换行为不同,而且可能中断。激活记录与已安装产物故障测试必须证明每次文件系统移动都可以恢复。 +中断的包操作保留未完成标记。已安装产物测试必须验证后续启动会重试锁定依赖的安装和获准的原生构建。 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。 + +## 相关提案 + +[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)取代本记录的离线 运行时准备与单项目包归属决策。发布身份、签名、无端口传输、进程归属及仅限 Electron 的包授权仍由本记录负责。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index f20124e9f9..2f925cf952 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 8e1e12a8536f56c9ccb515cec4c07c730c8b9b84 -2026-08-28-subprocess-native-containment.zh.md: 83bc20a29d937bca9530ca107712e4a7b39d8d4d +2026-08-28-subprocess-native-containment.md: 7523f9d66e7a302f6ce9c77d93671a5b1303a5aa +2026-08-28-subprocess-native-containment.zh.md: 252a8e9fd058cf37a1a7a70f09394a6811d58580 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 8e1e12a853..7523f9d66e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -22,7 +22,7 @@ The first eligible Linux ordinary or PTY call in one runtime deeply checks the e The parent creates one 0700 directory with a complete 0600 `launch-request.json` containing the final target cwd and environment. The private `DSH_SUBPROCESS_RUNNER` value locates that request while the runner starts from the provider cwd and a bootstrap-safe environment. `systemd-run --user --scope --quiet --collect --expand-environment=no` registers its process in the scope, then the one-shot bootstrap removes and validates the request, changes to the target cwd, restores the complete target environment, resolves a bare executable with the target PATH rules, clears `FD_CLOEXEC` on fd 0 through fd 2, and calls libc `execve()` with the original argv. The bootstrap becomes the target in place and preserves its inherited stdio; it does not remain as a supervisor. -Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads both `LoadState` and `ActiveState`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion. +Request consumption or a manager observation of a loaded unit establishes scope ownership. Unit absence before either fact remains unresolved while the direct launcher is running. If that launcher exits while the request remains unconsumed, the direct result rejects with startup failure unless its observed signal matches a termination requested while the launcher was running. A matching signal preserves the actual exit outcome for both ordinary and PTY launches; a recorded startup error always takes precedence. Range observation independently resolves an empty range when the launcher has exited and the unit is absent. The parent checks this unresolved interval every 50 milliseconds; after establishment, state queries back off exponentially to the existing 5-second systemctl bound. Each query reads `LoadState`, `ActiveState`, and `TasksCurrent`: loaded `inactive` or `failed`, or an established unit becoming `not-found`/`inactive` or otherwise collected away, proves the range empty. `active`, `activating`, `reloading`, and `deactivating` remain nonterminal, except that once termination was requested and the launcher has exited, a still-active unit reporting no processes is an empty range: the manager ends a scope only on the populated-to-empty transition, so a payload killed before it entered the cgroup never triggers one. That conclusion stops the leftover unit so transient units cannot accumulate, while an unreported or `[not set]` process count stays unknown and keeps waiting. Unknown or malformed combinations and unreadable manager results reject `waitForExit()` instead of claiming quiescence. `terminate()` wakes a sleeping observer for an immediate recheck, and settlement cancels the losing backoff sleep. A strict sibling `startup-error.json` carries only request/bootstrap or target pre-exec failure, and the parent removes this spawn's private paths at observable lifecycle completion. The ordinary target result still comes from the same child process. The PTY path uses the same request and bootstrap without a resident runner, so the `node-pty` PID, process group, session leader, controlling terminal, foreground `inputWaiting`, `/dev/tty`, readiness, and direct terminal outcome retain their existing meanings while scope membership covers `setsid` and reparented descendants. @@ -54,7 +54,7 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification -- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState` parsing, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. +- Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement. - Real Linux user-systemd tests run one ordinary and one `node-pty` `setsid`/reparent scenario through the production entry. They prove scope signalling and collection, bare executable lookup, escaped-descendant termination, range settlement, and unchanged PTY PID, session, controlling-terminal, foreground-input, `/dev/tty`, readiness, and startup-failure semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index 83bc20a29d..252a8e9fd0 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -22,7 +22,7 @@ detached POSIX 进程组、Windows direct-parent 遍历与 PTY 后代扫描只 parent 创建一个 0700 目录,其中的完整 0600 `launch-request.json` 保存最终 target cwd 与环境。私有 `DSH_SUBPROCESS_RUNNER` 值负责定位该 request,runner 则从 provider cwd 与 bootstrap-safe 环境启动。`systemd-run --user --scope --quiet --collect --expand-environment=no` 先把自身进程注册到 scope,再由 one-shot bootstrap 删除并校验 request、切换到 target cwd、恢复完整 target 环境、按 target PATH 规则解析裸可执行文件、清除 fd 0 至 fd 2 的 `FD_CLOEXEC`,并使用原始 argv 调用 libc `execve()`。bootstrap 会原地成为 target 并保留继承的 stdio,不作为常驻 supervisor。 -request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询同时读取 `LoadState` 与 `ActiveState`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 +request 被消费或 manager 已观察到 loaded unit 都能建立 scope ownership。在这两项事实出现前,只要 direct launcher 仍在运行,unit absence 就保持未决。如果 launcher 退出时 request 仍未消费,direct result 会以 startup failure reject,除非实际观察到的信号匹配 launcher 仍在运行时请求的终止信号。普通进程与 PTY 进程遇到匹配信号时都会保留实际退出结果;已记录的 startup error 始终优先。launcher 已退出且 unit 不存在时,range observation 会独立结算 empty-range wait。parent 每 50 毫秒检查一次这段未决区间;建立后,状态查询按指数增长间隔退避,最多达到既有的 5 秒 systemctl 上限。每次查询读取 `LoadState`、`ActiveState` 与 `TasksCurrent`:loaded `inactive` 或 `failed`,以及已经建立的 unit 变为 `not-found`/`inactive` 或被 collect 卸载,都能证明 range 为空。`active`、`activating`、`reloading` 与 `deactivating` 仍是非终态;例外是:一旦请求过终止且 launcher 已经退出,报告没有任何进程却仍 active 的 unit 就是空 range——manager 只在观测到 populated→empty 转变时才结束 scope,因此进入 cgroup 前就被杀死的 payload 永远不会触发该转变。该判定会 stop 掉这个遗留 unit,使 transient unit 不会累积;未上报或为 `[not set]` 的进程数仍视为未知并继续等待。未知或 malformed 组合以及不可读的 manager 结果会使 `waitForExit()` reject,而不是宣称完全停稳。`terminate()` 会唤醒正在休眠的 observer 立即复查,结算时会取消未胜出的退避 sleep。严格的同目录 `startup-error.json` 只承载 request/bootstrap 或 target pre-exec failure,parent 会在可观察生命周期完成时移除本次 spawn 的私有路径。 普通 target result 仍来自同一个 child process。PTY 路径复用同一 request 与 bootstrap,但不增加常驻 runner,因此 `node-pty` PID、进程组、session leader、控制终端、前台 `inputWaiting`、`/dev/tty`、readiness 与 direct terminal outcome 保留既有含义,同时 scope membership 覆盖 `setsid` 与 reparent 后代。 @@ -54,7 +54,7 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification -- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState` 解析、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 +- provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。 - 真实 Linux user-systemd 测试会分别通过生产入口运行一条普通命令与一条 `node-pty` `setsid`/reparent 场景。它们证明 scope signalling 与 collection、裸可执行文件查找、逃逸后代终止、range settlement,以及不变的 PTY PID、session、控制终端、前台输入、`/dev/tty`、readiness 与 startup-failure 语义。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml new file mode 100644 index 0000000000..bd9e5a5bf2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md +2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 581a4ad31121bfda651cb99e550487a0a712e943 +2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: fbbb25a4a3446d52ac56dc35bb176c0b706d7cb4 diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md new file mode 100644 index 0000000000..581a4ad311 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md @@ -0,0 +1,61 @@ +# Agent Note: Bundle the Desktop runtime and retain external plugins + +Status: implemented + +English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md) + +Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). + +## Problem + +Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades. + +Separate package directories can load duplicate Cordis or service modules. Retaining plugin files also does not prove compatibility with a new host API or Node runtime. + +## Decision + +[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`. + +Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived. + +## Package ownership + +The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization. + +The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability. + +Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin. + +Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations. + +External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient. + +The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them. + +## Transactions and upgrades + +First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links and validate enabled peers in place. Node version, platform, or architecture changes reinstall the locked plugin graph and run approved native builds. + +Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation. + +Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction. + +Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and persistent retry state. Recorded host links identify owned directories independently of package-operation completion. + +The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Each backend launch requires the current runtime identity. + +## Alternatives considered + +Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory. + +- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility. +- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions. +- **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target. +- **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable. +- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths. + +## Consequences + +Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails. + +The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them. diff --git a/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md new file mode 100644 index 0000000000..fbbb25a4a3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md @@ -0,0 +1,61 @@ +# Agent Note: 内置 Desktop 运行时并保留外部插件 + +Status: implemented + +[English](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) | 中文 + +profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 + +## 问题 + +Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。 + +分离的包目录可能加载重复的 Cordis 或服务模块。仅保留插件文件也不能证明它与新的宿主 API 或 Node 运行时兼容。 + +## 决策 + +[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。 + +Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档。 + +## 包归属 + +资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。 + +[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。 + +包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。 + +dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。 + +外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。 + +profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们。 + +## 事务与升级 + +首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接并验证已启用的 peer。Node 版本、平台或架构变化时,会重新安装锁定的插件依赖图并运行获准的原生构建。 + +共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。 + +依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。 + +Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和持久重试状态的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立。 + +[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。每次后端启动都要求当前运行时标识。 + +## 考虑过的替代方案 + +完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。 + +- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。 +- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。 +- **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。 +- **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。 +- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。 + +## 影响 + +首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。 + +[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。 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 new file mode 100644 index 0000000000..1b6639b303 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md +2026-09-09-consumer-owned-startup-strictness.md: e009e66ead25ef0a5e6001d33663e32bc04d19d2 +2026-09-09-consumer-owned-startup-strictness.zh.md: 58c364056f5b0dc41e018cd5488be983662401d4 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 new file mode 100644 index 0000000000..e009e66ead --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.md @@ -0,0 +1,39 @@ +# Agent Note: Consumer-owned startup strictness + +Status: implemented + +English | [中文](2026-09-09-consumer-owned-startup-strictness.zh.md) + +## Problem + +Best-effort Loader reconciliation preserves usable plugins, but applications still need a minimum set of capabilities. An HTTP application without its listening server is not running, while an unavailable tool can be omitted without making the remaining application unusable. Cordis cannot infer this distinction from plugin implementation or dependency state. + +## Decision + +DSH owns startup strictness outside vendored Cordis. App-boot audits the settled initial tree against one global list of stable entry ids. A listed entry that is present, enabled, and not active rejects startup and disposes the application. A listed id that is absent or disabled has no effect. The bootstrap Include is required by entry identity because a missing or invalid root configuration prevents application assembly. Other inactive entries produce one warning and leave successful siblings running. + +The required ids are `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. They represent shared Agent execution, application endpoints, and Web bootstrap/transport. Web needs its client module registry and authenticated connection even when the HTTP server can listen without them. Providers already required through injection need no separate entry: their absence leaves a listed consumer pending or failed. + +The audit treats a throwing `disabled` expression as an entry failure, not a disabled entry, because evaluation never established whether to skip it. The same optional/required policy applies to that failure. + +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. + +## Alternatives considered + +- **Add transactional and best-effort modes to vendored Loader.** Rejected because strictness belongs to the application or resource owner, while a Loader group contains unrelated plugins. A mode would also expand the vendor patch and leave callers to select a policy at every group. +- **Declare required entries in each profile.** Rejected because the same application endpoints would be duplicated across profile data and custom profiles. A global list treats missing ids as irrelevant while keeping stable shipped ids authoritative. +- **Make every startup failure optional.** Rejected because a process that cannot expose its selected application endpoint must report launch failure. + +## Consequences + +Stable required entry ids are part of application assembly. Renaming one requires updating the list and its tests. Optional plugin failures remain visible in Loader state and stderr without tearing down active siblings. Required failures use the same detailed import, activation, or pending-service diagnostic before app-boot disposes the root. + +## Testing + +App-boot unit tests cover absent and disabled required ids, optional import failure, config evaluation failure, synchronous and asynchronous `apply()` failure, pending dependencies, and required failure teardown. The built Web-profile acceptance serves the full UI with optional failures and exits nonzero without readiness when the required HTTP port is occupied or `modules` or `connection` cannot activate. + +The [Web process matrix](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) independently exercises optional and required failures at startup and after native patch-file edits. Authenticated HTTP requests and plugin lifecycle files distinguish a usable application from a surviving process. These keyless process checks complement the [controlled-delivery unit tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md): unit tests isolate reconciliation failures, while the process tests also require the shipped launcher, native watcher, and bounded shutdown to work together. + +The matrix enables Chokidar's `awaitWriteFinish` to acknowledge stable file contents before each reload; otherwise its short change-event suppression window can discard the next test edit. Native events remain required, and assertions wait for observed activation or failure rather than a fixed settling sleep. This is explicit test configuration, not evidence for the default watcher timing. diff --git a/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md new file mode 100644 index 0000000000..58c364056f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-consumer-owned-startup-strictness.zh.md @@ -0,0 +1,39 @@ +# Agent Note:由 consumer 持有启动严格语义 + +Status: implemented + +[English](2026-09-09-consumer-owned-startup-strictness.md) | 中文 + +## 问题 + +Best-effort Loader reconcile 会保留可用 plugin,但应用仍需一组最小 capability。HTTP 应用没有 listening server 就不算运行,而一个 tool 不可用时可以仅省略该 tool,剩余应用仍然可用。Cordis 无法从 plugin 实现或依赖状态推断这一区别。 + +## 决策 + +DSH 在 vendored Cordis 之外持有启动严格语义。App-boot 用一份全局稳定 entry id list 审计已结算的初始 tree。List 中存在、启用且未 active 的 entry 会使启动 reject,并拆卸应用。List 中缺失或禁用的 id 不产生影响。Bootstrap Include 按 entry 身份被视为 required,因为根配置缺失或无效会阻止应用组装。其他 inactive entry 输出一次 warning,并让成功 sibling 继续运行。 + +Required id 为 `agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。它们分别代表共享 Agent 执行、应用 endpoint,以及 Web 启动与传输。即使 HTTP server 不依赖它们也能监听,Web 仍需要客户端模块注册表和经过认证的连接。通过注入已成为必需项的 provider 不需要单列:它们缺失时,已列出的消费方会保持 pending 或失败。 + +审计将 `disabled` 表达式抛出的异常视为 entry 失败,而不是 entry 已禁用,因为求值未能确定是否跳过它。该失败遵循相同的 optional/required 策略。 + +该审计只在应用首次启动时运行。之后的 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)持有独立的严格子树审计。 + +## 考虑过的替代方案 + +- **给 vendored Loader 增加 transactional 与 best-effort mode。** 拒绝,因为严格语义属于应用或资源 owner,而一个 Loader group 包含互不相关的 plugin。Mode 还会扩大 vendor patch,并要求 caller 为每个 group 选择 policy。 +- **在每个 profile 中声明 required entry。** 拒绝,因为相同应用 endpoint 会在 profile data 与 custom profile 中重复。全局 list 会忽略缺失 id,同时让稳定的随附 id 保持权威。 +- **把所有启动失败都视为 optional。** 拒绝,因为无法暴露所选应用 endpoint 的进程必须报告启动失败。 + +## 后果 + +稳定的 required entry id 是应用 assembly 的一部分。重命名时必须同步更新 list 与测试。Optional plugin failure 会保留在 Loader state 和 stderr 中,但不会拆卸 active sibling。Required failure 使用相同的详细 import、activation 或 pending-service 诊断,然后由 app-boot 拆卸 root。 + +## 测试 + +App-boot 单元测试覆盖缺失和禁用的 required id、optional import failure、config evaluation failure、同步和异步 `apply()` failure、pending dependency,以及 required failure teardown。构建后的 Web-profile acceptance 会在 optional failure 存在时继续提供完整 UI,并在 required HTTP port 被占用或 `modules`、`connection` 无法激活时以非零码退出,且不报告就绪。 + +[Web 进程矩阵](../../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)分别验证启动时和原生补丁文件修改后的 optional 与 required 失败。经过认证的 HTTP 请求和插件生命周期文件区分可用应用与仅存活的进程。这些无需密钥的进程检查与[受控事件投递单元测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)互补:单元测试隔离配置协调失败,进程测试还要求随附启动器、原生监听器和有界关闭流程协同工作。 + +矩阵启用 Chokidar 的 `awaitWriteFinish`,在每次重载前确认文件内容已稳定;否则它的短暂 change 事件抑制窗口可能丢弃下一次测试编辑。测试仍然依赖原生事件,并等待观察到激活或失败,而不是固定时长的休眠。这是显式测试配置,不能证明默认监听器的时序行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml new file mode 100644 index 0000000000..0cae49b087 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md +2026-09-09-deprecate-synchronous-session-event-reads.md: a2a86b0d6500269531ca1088738aaa612076bfb4 +2026-09-09-deprecate-synchronous-session-event-reads.zh.md: 38468708a5323b6ebf94377f3cc8f2c86dc95dde diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md new file mode 100644 index 0000000000..a2a86b0d65 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md @@ -0,0 +1,51 @@ +# Agent Note: Deprecate synchronous reads of arbitrary Session events + +Status: implemented + +English | [中文](2026-09-09-deprecate-synchronous-session-event-reads.zh.md) + +## Problem + +Synchronous access to arbitrary event positions makes consumers depend on the complete Session event sequence being immediately available in memory. The storage direction is to stop retaining that complete sequence in memory. Once historical events require storage I/O, the runtime cannot preserve the same synchronous read guarantee without retaining the history or blocking on storage. + +New callers increase that dependency even when they read only one old event. Repeated history scans after resume also make ordinary domain logic depend on historical storage instead of the state it actually needs. + +## Decision + +All operations that synchronously read arbitrary positions or ranges of Session event history are deprecated, including `Session.eventAt()`, `Session.snapshotEvents()`, and `Session.ownEvents()`. Existing logic may remain unmigrated for now, but new calls are prohibited. New aliases or wrappers that expose the same synchronous historical access are prohibited as well. + +The three methods carry this rule in `@deprecated` JSDoc. This is an API-use decision; the current Session implementation still retains the complete event sequence in memory. + +Repository test files, including `scripts/**/*.spec.{ts,tsx}`, may call these three readers to inspect emitted events and exercise Session history behavior. The test-file lint override allows `snapshotEvents`, `eventAt`, and `ownEvents`; all other deprecated names remain errors. This allowance also covers unrelated declarations with the same three names under the current linter. It does not apply to production source or non-test repository scripts. + +### State needed after resume + +Design durable event fields and Session projections together so each domain can reconstruct the state its consumers need. Restore that state during resume, then maintain it incrementally from newly committed events. After resume, ordinary logic reads the projection or processes the delivered current event instead of looking back through historical events. Reading already-maintained projection state synchronously does not require arbitrary access to the event log. + +Historical content presented on demand uses explicit asynchronous pagination and progressive loading, with each read limited to the requested window. Loading the complete sequence behind a synchronous helper preserves the dependency this decision removes. + +### Operations that require complete history + +Fork and a small number of operations may genuinely need a complete historical sequence or inherited prefix. Their need for those records remains valid and requires an explicit storage read. It does not require the whole sequence to remain resident or grant an exception for new calls to deprecated synchronous readers. Each such consumer must establish why its result requires the complete sequence rather than projected state or a limited historical window. + +## Alternatives considered + +**Keep synchronous single-event reads while deprecating only full snapshots.** A single requested event may also be absent from memory. Restricting the result size does not remove the storage dependency, and helpers such as `ownEvents()` retain the same assumption for a suffix. + +**Keep the complete sequence resident to preserve the read APIs.** This lets consumer convenience dictate Session memory retention and prevents the intended storage design. Projections preserve required state, while explicit historical reads preserve access to the records themselves. + +**Require every existing caller to migrate immediately.** Existing logic may defer migration under this decision. Preventing new dependencies bounds the remaining work without making every existing consumer part of the same change. + +**Disable deprecation lint throughout test files or exempt production readers by name.** Tests only need the three reader names; other deprecated APIs must remain errors. Production code retains the prohibition on new synchronous reads. + +**Add a separate test-only reader API.** The three existing readers already expose the observations these tests need. Wrapping them adds an API and production-import checks without changing those observations. + +## Consequences + +New domain behavior must make its event data and projected state sufficient for resumed execution. User-requested history may still load progressively, and genuine full-history operations still have a storage path to design. This decision does not claim that resume or fork already avoids loading the complete log. + +Calls outside test files carry line-scoped `typescript/no-deprecated` waivers that identify deferred migration or delegation between deprecated readers. Remove a waiver when its deprecated call is removed; copying a waiver to a new production call violates this policy. The executable lint check accepts test reads and existing waived reads, rejects unwaived production reads, and rejects unrelated deprecated APIs in tests. Documentation checks verify the source-equivalent API declarations and bilingual records. + +## Related decisions + +The [session immutability decision](2026-06-11-dev-invariants-over-deep-readonly.md) continues to own event ownership and freezing; this decision supersedes its recommendation to use synchronous historical readers. The [required projection reader decision](2026-08-19-session-projection-mandatory-seam.md) continues to own missing-state failures and typed state reads. The [recallable compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) retains its recall design while replacing its proposed synchronous history access with paged reads. diff --git a/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md new file mode 100644 index 0000000000..38468708a5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 弃用对会话任意位置事件的同步读取 + +Status: implemented + +[English](2026-09-09-deprecate-synchronous-session-event-reads.md) | 中文 + +## 问题 + +同步访问任意事件位置,使消费方依赖完整会话事件序列在内存中随时可用。存储的演进方向是不再将完整序列保存在内存中。历史事件一旦需要存储 I/O,运行时若不继续保留历史或阻塞等待存储,就无法维持相同的同步读取保证。 + +即使只读取一个旧事件,新增调用也会加深这种依赖。恢复后反复扫描历史,还会使普通领域逻辑依赖历史存储,而不是它真正需要的状态。 + +## 决策 + +弃用所有同步读取会话事件历史任意位置或区间的操作,包括 `Session.eventAt()`、`Session.snapshotEvents()` 和 `Session.ownEvents()`。现有逻辑可以暂不迁移,但禁止新增调用。同样禁止新增暴露相同同步历史访问能力的别名或包装层。 + +这三个方法通过 `@deprecated` JSDoc 声明该规则。这是 API 使用决策;当前 Session 实现仍在内存中保留完整事件序列。 + +仓库测试文件(包括 `scripts/**/*.spec.{ts,tsx}`)可以调用这三个读取方法,以检查已发出的事件并验证 Session 历史行为。测试文件 lint override 允许 `snapshotEvents`、`eventAt` 和 `ownEvents`,其他弃用名称仍报错。在当前 linter 下,此豁免也覆盖使用这三个名称的其他声明。它不适用于生产源码或非测试仓库脚本。 + +### 恢复后需要的状态 + +结合设计持久事件字段与会话投影,使每个领域都能重建其消费方所需的状态。在恢复期间还原这些状态,随后通过新提交的事件增量维护。恢复后,普通逻辑读取投影或处理当前收到的事件,而不是回头查找历史事件。同步读取已经维护好的投影状态,不需要任意访问事件日志。 + +按需展示的历史内容采用显式异步分页和渐进式加载,每次读取限制在请求的窗口内。在同步辅助方法背后加载完整序列,仍保留了本决策要消除的依赖。 + +### 确实需要完整历史的操作 + +fork 等少数操作可能确实需要完整历史序列或继承前缀。它们对这些记录的需求仍然成立,需要通过显式存储读取来满足。这不要求完整序列常驻内存,也不构成新增已弃用同步读取调用的例外。每个此类消费方都必须说明,为什么其结果需要完整序列,而不能只使用投影状态或局部历史窗口。 + +## 曾考虑的替代方案 + +**只弃用完整快照,保留同步单事件读取。** 单个被请求的事件也可能不在内存中。限制返回结果大小并不能消除存储依赖,`ownEvents()` 等辅助方法对后缀序列也保留着相同假设。 + +**为保留读取 API 而让完整序列常驻内存。** 这会让消费方的便利性决定会话的内存保留方式,阻碍预期的存储设计。投影保留所需状态,显式历史读取则保留对记录本身的访问能力。 + +**要求立即迁移所有现有调用方。** 本决策允许现有逻辑推迟迁移。禁止新增依赖可以限制剩余工作规模,无需将每个现有消费方都纳入同一次修改。 + +**在整个测试文件中禁用弃用 lint,或按名称豁免生产代码中的读取方法。** 测试只需要这三个读取方法名;其他弃用 API 必须继续报错。生产代码仍禁止新增同步读取。 + +**添加单独的测试专用读取 API。** 三个现有读取方法已经提供这些测试所需的观察结果。包装它们会增加一个 API 及生产代码导入检查,却不会改变这些观察结果。 + +## 后果 + +新增领域行为必须使其事件数据与投影状态足以支持恢复后的执行。用户请求的历史仍可渐进式加载,确实需要完整历史的操作仍需设计存储读取路径。本决策不表示恢复或 fork 已经能够避免加载完整日志。 + +测试文件之外的调用带有逐行的 `typescript/no-deprecated` 豁免,说明暂缓迁移或已弃用读取方法之间的委托。删除已弃用调用时应一并删除其豁免;将豁免复制到新增生产调用违反本策略。实际执行 lint 的检查允许测试读取和已有豁免调用,拒绝未豁免的生产读取,并拒绝测试中其他已弃用 API。文档检查校验与源码一致的 API 声明及双语记录。 + +## 相关决策 + +[会话不可变性决策](2026-06-11-dev-invariants-over-deep-readonly.zh.md)继续负责事件所有权与冻结;本决策取代其中对同步历史读取方法的使用建议。[投影读取必需性决策](2026-08-19-session-projection-mandatory-seam.zh.md)继续负责缺失状态时的失败规则与类型化状态读取。[可回溯压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.zh.md)保留其回溯设计,并将其中拟议的同步历史访问替换为分页读取。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml new file mode 100644 index 0000000000..c41d31830d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md +2026-09-09-desktop-build-release-validation.md: 23aeeaa98d2647f8b9f54e94ea2aedaa82b19e55 +2026-09-09-desktop-build-release-validation.zh.md: 266bca5bbd593907c9a8fadd43050113ede94100 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md new file mode 100644 index 0000000000..23aeeaa98d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.md @@ -0,0 +1,23 @@ +# Agent Note: Verify Desktop release compatibility during packaging + +Status: implemented + +English | [中文](2026-09-09-desktop-build-release-validation.zh.md) + +## Problem + +The shell and runtime descriptor ship together. Comparing their release facts on every launch repeats packaging checks without proving that installed executable bytes match the descriptor. + +## Decision + +The packaging verifier owns descriptor schema, shell version, platform, architecture, declared Host protocol version, and Node/pnpm semver validation. Startup reads the fields needed for profile preparation and retains shared-package record and Host-entry checks. The actual Host ready message still validates its protocol version. + +This partially supersedes startup release compatibility checks in the [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). That note retains package ownership and distribution rationale. + +## Alternatives considered + +Repeating descriptor comparisons can diagnose a mixed installation earlier, but cannot establish executable integrity. Reintroducing them requires a concrete installation failure that packaging validation and actual Host diagnostics cannot adequately explain. + +## Consequences + +Startup does not reject a descriptor solely because its declared release schema, target, or Host protocol differs, or its Node/pnpm version strings are not semver. Packaging still rejects these cases and shell-version mismatch. Tests distinguish startup reads from packaging verification. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md new file mode 100644 index 0000000000..266bca5bbd --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-build-release-validation.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 在打包时验证 Desktop 发布兼容性 + +Status: implemented + +[English](2026-09-09-desktop-build-release-validation.md) | 中文 + +## 问题 + +壳与运行时描述文件一起发布。每次启动比较其中的发布信息会重复打包检查,却不能证明已安装的可执行文件字节与描述文件一致。 + +## 决策 + +打包验证器负责描述文件 schema、shell 版本、平台、架构、声明的 Host 协议版本,以及 Node/pnpm semver 验证。启动读取准备 profile 所需的字段,并保留共享包记录和 Host 入口检查。实际 Host ready 消息仍然验证其协议版本。 + +本决策部分取代[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的启动发布兼容性检查。该记录继续负责包归属与分发的理由。 + +## 考虑过的替代方案 + +重复比较描述文件能更早诊断混装,但不能证明可执行文件完整性。重新引入这些比较需要具体的安装故障,且打包验证和实际 Host 诊断无法充分解释该故障。 + +## 后果 + +启动不会仅因描述文件声明的发布 schema、目标或 Host 协议不同,或 Node/pnpm 版本字符串不是 semver 而拒绝运行。打包仍拒绝这些情况和 shell 版本不匹配。测试区分启动读取与打包验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml new file mode 100644 index 0000000000..aa2dec8161 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md +2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b +2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md new file mode 100644 index 0000000000..1c2209c044 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md @@ -0,0 +1,33 @@ +# Agent Note: Show the Desktop window before starting the Host + +Status: implemented + +English | [中文](2026-09-09-desktop-immediate-window-and-direct-start.zh.md) + +Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md). + +## Problem + +Waiting for backend readiness leaves users without a window during profile preparation and module loading. A complete staged health-check process repeats backend startup before the application starts its serving process, while plugin startup can still fail in the serving process. + +## Decision + +Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit. + +The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available only in a packaged application with loaded runtime metadata and available resources. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again. + +Desktop starts the actual Host after preparing the profile in place, without booting a separate health-check backend. Package mutations retain dependency validation, approved lifecycle builds, runtime identity checks, and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback. + +This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation. + +## Alternatives considered + +**Keep a complete staged health check.** It can reject some startup failures before activation, but executes plugin initialization twice and cannot guarantee that the serving process will start. The actual Host result provides the diagnostic needed for explicit recovery. + +**Keep the main window hidden until readiness.** This avoids presenting a loading page but gives users no visible progress or interaction while the backend loads. A shell-owned page can remain available when Host startup fails. + +## Consequences + +Users can see startup progress and recover from failures before the product UI is available. A responsive window does not imply that the backend is ready, and startup latency still requires installed-artifact measurement. Profile changes remain in place after activation fails. + +Verification covers a delayed Host with a visible loading page, one serving startup for a fresh profile, failure and retry in the same window, plugin management during recovery, and closing while a child is starting. Installed GUI evidence complements lifecycle and transaction tests. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md new file mode 100644 index 0000000000..49b08e4eee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Show the Desktop window before starting the Host + +Status: implemented + +[English](2026-09-09-desktop-immediate-window-and-direct-start.md) | 中文 + +profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。 + +## 问题 + +等待后端就绪会让用户在准备 profile 和加载模块期间看不到窗口。完整的 staging 健康检查进程会在应用启动服务进程前重复启动后端,而插件在实际服务进程中仍然可能启动失败。 + +## 决策 + +Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。 + +主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。只有已打包应用加载了运行时元数据且资源可用时,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。 + +Desktop 直接准备 profile 后启动实际 Host,不另行启动健康检查后端。包修改保留依赖验证、获准生命周期构建、运行时标识检查和锁。失败后保留部分修改,供显式修复;不自动回滚 profile。 + +本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。 + +## 考虑过的替代方案 + +**保留完整 staging 健康检查。** 它可以在激活前拒绝部分启动失败,但会执行两次插件初始化,也不能保证服务进程能够启动。实际 Host 结果提供显式恢复所需的诊断。 + +**在就绪前隐藏主窗口。** 这避免显示加载页,但后端加载期间用户看不到进度,也无法交互。壳拥有的页面可以在 Host 启动失败时继续使用。 + +## 后果 + +用户可以在产品 UI 可用前看到启动进度并从失败中恢复。窗口能够响应不代表后端已经就绪,启动延迟仍需通过已安装产物测量。激活失败后,profile 修改保留在原位。 + +验证覆盖 Host 延迟时可见的加载页、新 profile 只启动一次服务进程、同一窗口中的失败与重试、恢复期间的插件管理,以及子进程正在启动时关闭应用。安装后 GUI 证据补充生命周期与事务测试。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml new file mode 100644 index 0000000000..071c6eb3c0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md +2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385 +2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287 diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md new file mode 100644 index 0000000000..9a221bb334 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md @@ -0,0 +1,27 @@ +# Agent Note: Modify the Desktop profile in place + +Status: implemented + +English | [中文](2026-09-09-desktop-in-place-profile.zh.md) + +## Problem + +Staging preserves an old plugin installation but adds profile copying, directory moves, a recovery journal, and rollback state. Local plugin changes accept explicit repair after failure instead of this complexity. + +## Decision + +Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking, dependency validation, and approved native builds remain. Compatible upgrades refresh links without copying plugin files. + +Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted. + +This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active. + +A persistent `desktop-packages-pending` marker precedes package writes or native-runtime rebuilding and is removed only after installation, approved builds, and validation succeed. A later launch with that marker reinstalls the locked graph and retries pending builds even when recorded runtime metadata already matches. Ordinary unchanged startups reuse the profile without scanning the plugin dependency graph; package mutations and runtime reconciliation retain validation. + +## Alternatives considered + +Staging protects the previous installation at the cost of copying and crash recovery. Versioned directories still need preparation, selection, and cleanup. Direct writes give up automatic recovery; reintroduction requires an unattended-recovery product requirement that justifies these costs. + +## Consequences + +Tests cover offline initialization, in-place upgrades, failure before writes, retained changes after Host failure, partial pnpm failure, restored host links, and exclusive package ownership. Signed application and GUI acceptance remain release-environment checks. diff --git a/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md new file mode 100644 index 0000000000..53a744ae9e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 直接修改 Desktop profile + +Status: implemented + +[English](2026-09-09-desktop-in-place-profile.md) | 中文 + +## 问题 + +staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢复日志和回滚状态。本地插件变更接受失败后显式修复,以避免这些复杂度。 + +## 决策 + +Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁、依赖验证和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。 + +包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。 + +本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。 + +持久的 `desktop-packages-pending` 标记先于包写入或原生运行时重建,仅在安装、获准构建和验证成功后删除。后续启动发现该标记时,会重新安装锁定的依赖图并重试待执行构建,即使记录的运行时元数据已经匹配。普通未变化的启动复用 profile,不扫描插件依赖图;包修改和运行时校准保留验证。 + +## 考虑过的替代方案 + +staging 以复制和崩溃恢复为代价保护旧安装。版本化目录仍需要准备、选择和清理。直接写入放弃自动恢复;只有无人值守恢复的产品要求能证明这些成本合理时,才重新引入。 + +## 后果 + +测试覆盖离线初始化、原地升级、写入前失败、Host 失败后保留修改、pnpm 部分失败、宿主链接恢复及独占包操作。签名应用和 GUI 验收仍由发布环境负责。 diff --git a/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml new file mode 100644 index 0000000000..5ea95513d9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md +2026-09-10-command-identities-and-composer-file-action.md: 696c3486c5e007416da3fd5e7be1d4b41cb5f582 +2026-09-10-command-identities-and-composer-file-action.zh.md: 2ce803bad5d39a6d4f40b665fb4ef7456e987912 diff --git a/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md b/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md new file mode 100644 index 0000000000..696c3486c5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.md @@ -0,0 +1,33 @@ +# Agent Note: Command identities and composer-owned File action + +Status: implemented + +English | [中文](2026-09-10-command-identities-and-composer-file-action.zh.md) + +## Problem + +Matching a command's English description to a client dictionary makes punctuation changes affect localization and inserted command tokens. A same-name override can also copy that description without implementing the first-party command. The File menu entry needs the composer's live attachment policy, including mount, lock, and submission state; a separate command-plugin check cannot determine that state. + +## Decision + +The command registry preserves an optional branded `CommandDefinitionId` as `definitionId` on the effective definition and descriptor. First-party producers choose their package name as the stable identity. Scoped shadowing selects the complete descriptor and never inherits the shadowed definition's identity. The identifier is discovery metadata, not an authorization claim, and does not enter command lifecycle events. + +The client command directory resolves input through its private `resolution.ts`. Exact registered names take priority; Chinese and English aliases select only the corresponding first-party definition in the effective Session catalog. Menu claims use the current locale's spelling; typed claims retain the supplied spelling; submissions use the resolved registered name. `presentation.ts` owns only sections, labels, descriptions, and icons. Resolution helpers and section constants are not exported from the plugin entrypoint. + +Conversation registers the File action through the injected command service and owns its localized label. The mounted input binds its file-dialog opener and one live availability query. Both menu filtering and invocation use that query, so lock, unmount, subagent, and submission state apply consistently. The binding and dispatch remain package-internal callbacks; no cross-plugin pick-files event is needed. The assembly uses a narrow structural action-registration face because command UI consumes Conversation's input types; a reverse compiler-project dependency would form a cycle. Its registration test checks against the command plugin's contribution type. + +This note supersedes only identity matching, input-resolution placement, and File-action ownership in the [composer menu decision](../feature/2026-09-08-composer-menu-sections-and-localized-rows.md). That note retains the menu, scrolling, claim-retention, and composition-timing decisions. + +## Alternatives considered + +**Match names and English descriptions.** Copy is editable and can be duplicated by unrelated definitions. A stable identity separates presentation selection from copy without moving localized text onto the Host. + +**Match names alone.** An agent-scoped override may deliberately provide a different command under the same name. It must not inherit first-party presentation or aliases unless it explicitly carries that identity. + +**Keep the File action in the command plugin.** The input owns attachment acceptance and the DOM lifetime. Maintaining a second availability condition splits one policy between owners. + +**Export helpers for reuse.** No production consumer needs the section constants or resolution helper as a plugin API. Tests import internal modules directly. + +## Consequences + +First-party producers and client identity mappings must agree on stable identifiers; third-party definitions may omit them. Display copy can evolve independently. Registry tests cover descriptor preservation and shadowing; client tests cover description edits in both locales, alias resolution, and exact-name priority. Composer tests cover live availability, opener replacement, unmount, and action-registration disposal. The existing Session-driven menu and command scenarios continue to own assembled browser output. diff --git a/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md b/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md new file mode 100644 index 0000000000..2ce803bad5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-command-identities-and-composer-file-action.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 命令标识与输入框拥有的文件动作 + +Status: implemented + +[English](2026-09-10-command-identities-and-composer-file-action.md) | 中文 + +## 问题 + +用命令的英文说明匹配客户端词典,会让标点修改影响本地化和填入的命令 token。同名覆盖也可能复制这段说明,却没有实现内置命令的行为。「文件」菜单项需要输入框实时的附件策略,包括挂载、锁定和提交状态,命令插件中的另一套检查无法确定这些状态。 + +## 决策 + +命令注册表在有效定义和描述符上保留可选的品牌类型 `CommandDefinitionId` 字段 `definitionId`。内置命令的提供方用包名作为稳定标识。作用域覆盖选择完整描述符,不继承被遮蔽定义的标识。该标识是发现元数据,不代表授权,也不进入命令生命周期事件。 + +客户端命令目录通过内部的 `resolution.ts` 解析输入。精确注册名优先,中英文别名只选择会话有效目录中对应的内置定义。菜单认领使用当前语言的写法,手输认领保留原写法,提交使用解析到的注册名。`presentation.ts` 只负责分节、标题、说明和图标。解析辅助函数与小节常量不从插件入口导出。 + +Conversation 通过注入的命令服务注册「文件」动作,并负责其本地化标题。已挂载输入框绑定文件选择器回调和一个实时可用性查询。菜单过滤与实际调用使用同一个查询,统一处理锁定、卸载、subagent 和提交状态。绑定与调用留在包内回调中,不需要跨插件的文件选择事件。组装层使用窄的结构化动作注册接口,因为命令 UI 已消费 Conversation 的输入类型,反向增加编译项目依赖会形成循环。注册测试使用命令插件的贡献项类型进行校验。 + +本记录只接管[输入框菜单决策](../feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md)中的标识匹配、输入解析归属和文件动作归属。原记录继续负责菜单、滚动、认领保留和组合输入时序的决策。 + +## 考虑过的替代方案 + +**按名称和英文说明匹配。** 文案可以修改,也可以被无关定义复制。稳定标识将展示选择与文案分开,无需把本地化文本移到宿主。 + +**只按名称匹配。** agent(智能体)作用域覆盖可能刻意在同名下提供另一种命令。除非它显式携带对应标识,否则不应继承内置展示或别名。 + +**把文件动作留在命令插件。** 输入框负责附件接收和 DOM 生命周期。维护另一套可用性条件会把同一策略分给两个模块。 + +**导出辅助函数以供复用。** 没有生产调用方需要把小节常量或解析辅助函数作为插件 API。测试直接导入内部模块。 + +## 后果 + +内置命令提供方与客户端映射必须使用一致的稳定标识,第三方定义可以省略标识。显示文案可以独立调整。注册表测试覆盖描述符保留和作用域覆盖,客户端测试覆盖两种语言下修改说明、别名解析和精确名称优先。输入框测试覆盖实时可用性、回调替换、卸载和动作注册的 dispose(资源释放)。现有会话驱动的菜单和命令场景继续负责组装后的浏览器输出。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml index 1f631789a8..89a74eaa00 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.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-07-31-fail-loud-releases-the-terminal.md -2026-07-31-fail-loud-releases-the-terminal.md: e5196121a850997b5eff045a26dd7c638776196c -2026-07-31-fail-loud-releases-the-terminal.zh.md: 5cec9fe7d4ac75df1b5c88f2ea4cb0a665730719 +2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed +2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md index e5196121a8..a6b8ccc5a0 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md @@ -15,7 +15,7 @@ $ 1;2;4cecho hello zsh: command not found: 4cecho ``` -The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.) +The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The [Loader activation audit](../simplification/2026-09-09-nontransactional-loader.md) reports config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.) Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md index 5cec9fe7d4..728fca424d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md @@ -17,7 +17,7 @@ zsh: command not found: 4cecho Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。 -在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 +在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。([Loader 激活检查](../simplification/2026-09-09-nontransactional-loader.zh.md) 让配置树失败经 `boot()` 报告,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。 `/exit` 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml new file mode 100644 index 0000000000..4f985d74f7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md +2026-09-10-built-bundle-css-exemption.md: e5a55689696f885b8ea9411a5b3a7598295fff35 +2026-09-10-built-bundle-css-exemption.zh.md: 1c1aab35df72497946b02bf2a7f67a2271c804e5 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md new file mode 100644 index 0000000000..e5a5568969 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md @@ -0,0 +1,27 @@ +# Agent Note: Built-bundle exemption for a failing stylesheet + +Status: implemented + +English | [中文](2026-09-10-built-bundle-css-exemption.zh.md) + +## Problem + +The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) exempts the Dockkit bundle because Node cannot load its stylesheets, and admitted one exact stylesheet path as the evidence: `packages/client/ui-dockkit/lib/components/dockkit.module.css`. The built bundle imports the workspace package `@deepseek-ai/dsh-client-ui-primitives` before its own stylesheet, and the `tsx` launcher resolves that specifier through tsconfig `paths` into the dependency's `src` tree, so the sweep reports `ERR_UNKNOWN_FILE_EXTENSION` for `packages/client/ui-primitives/src/StateDot.module.css`. The pinned path cannot match on a tree with client build output, and the Windows complete-gate inventory reported the exempt bundle as an unexpected baseline failure. + +## Decision + +The Dockkit exemption admits Node's unknown-`.css`-extension refusal for any stylesheet. Another extension, another error code, and an unrelated error message stay findings, as does an exempt bundle that imports cleanly. + +## Alternatives considered + +**Admit the dependency's source stylesheet alongside the pinned one.** That file is what the sweep reports, but the bundle's import order and the launcher's path mapping select it. Pinning it would certify those two details instead of the `.css` exemption. + +**Classify every CSS exemption by the same rule.** The Dockkit pin is the recorded evidence this change corrects; the other two stylesheet exemptions were never classified, and tightening them would change what they admit beyond the reported defect. + +**Drop the classification and admit any failure.** A bundle that stopped importing for an unrelated reason would then hide inside the exemption total. + +## Consequences + +The sweep reports the Dockkit bundle when it stops importing for any reason other than Node's unknown-`.css`-extension refusal, and the entry no longer asserts which stylesheet fails. [Scoped resolve/load hooks](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts) exercise an admitted Dockkit stylesheet, the dependency's source stylesheet, another extension, an arbitrary message, another error code, and a stale exemption without modifying shared build artifacts. + +The [CI observation decision](../testing/2026-09-08-ci-completion-observations.md) keeps the fixture completion and isolation decisions it owns; its built-client classification paragraph keeps the sweep summary and links here for the admitted evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md new file mode 100644 index 0000000000..1c1aab35df --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 构建产物豁免以失败的样式表为准 + +Status: implemented + +[English](2026-09-10-built-bundle-css-exemption.md) | 中文 + +## 问题 + +[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)因 Node 无法加载 Dockkit bundle 的样式表而豁免该 bundle,并曾以一个精确的样式表路径作为接受证据:`packages/client/ui-dockkit/lib/components/dockkit.module.css`。该已构建 bundle 在导入自身样式表之前先导入 workspace 包 `@deepseek-ai/dsh-client-ui-primitives`,`tsx` 启动器又通过 tsconfig `paths` 把这个说明符解析进依赖的 `src` 树,因此 sweep 报告的是 `packages/client/ui-primitives/src/StateDot.module.css` 的 `ERR_UNKNOWN_FILE_EXTENSION`。固定路径在带有 client 构建输出的树上无法匹配,Windows 完整门禁的清单于是把这个豁免 bundle 报告为意外的基线失败。 + +## 决策 + +Dockkit 豁免接受 Node 对任意样式表因未知 `.css` 扩展名而拒绝加载。其他扩展名、其他错误码和无关的错误消息仍计为发现,能顺利完成导入的豁免 bundle 同样计为发现。 + +## 考虑过的替代方案 + +**在固定路径之外同时接受依赖的源样式表。** sweep 报告的正是该文件,但选中它的是 bundle 的导入顺序和启动器的路径映射。固定该路径认定的将是这两处细节,而非 `.css` 豁免。 + +**用同一规则分类每个 CSS 豁免。** Dockkit 固定路径是本次修改所纠正的已记录证据;另外两个样式表豁免从未被分类,收窄它们会在所报告缺陷之外改变其接受的内容。 + +**放弃分类,接受任何失败。** 届时因无关原因停止导入的 bundle 会隐藏在豁免总数之内。 + +## 后果 + +只要 Dockkit bundle 因 Node 未知 `.css` 扩展名拒绝之外的任何原因停止导入,sweep 就会报告它,该条目也不再断言失败的是哪个样式表。[限定范围的 resolve/load hook](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts)覆盖被接受的 Dockkit 样式表、依赖的源样式表、其他扩展名、任意消息、其他错误码和陈旧豁免,不修改共享构建产物。 + +[CI 观察决策](../testing/2026-09-08-ci-completion-observations.zh.md)保留其拥有的 fixture 完成与隔离决策;其已构建 Client 导入分类段落保留 sweep 摘要,并就被接受的证据链接到本文。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml index 656c874bda..2ff4007876 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.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-09-10-deepseek-image-token-calculator-v41.md -2026-09-10-deepseek-image-token-calculator-v41.md: d8042b04c1ed7824720485aa3ccf584f913d0726 -2026-09-10-deepseek-image-token-calculator-v41.zh.md: 6be386da1c66c469329d03e7b86c8c0e2c22ce3a +2026-09-10-deepseek-image-token-calculator-v41.md: 668738221ebe3e634be7e4fe9d07c9627e0ceb4a +2026-09-10-deepseek-image-token-calculator-v41.zh.md: a483282c6da667c2b8e32accb091e065419d44fc diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md index d8042b04c1..668738221e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md @@ -12,7 +12,7 @@ English | [中文](2026-09-10-deepseek-image-token-calculator-v41.zh.md) `image-tokens.ts` is rewritten as a verbatim port of the `v41` configuration. The constants are a 14px patch, 3:1 per-axis downsampling, a 544×544 total-pixel floor, and a 1024-token cap. The grid formula is `rows × (cols + 1) + 2` with no odd-row extra row, no parity correction, and no even-row trimming in the solver. There is no alignment pad, so the estimate is exact rather than a worst-case upper bound, and there is no aspect-ratio clamp, so extreme aspect ratios reach the cap through the solver's one-row and one-column branches. The over-budget path is a single closed-form solve followed by the published assertion; the decrementing retry loop existed only for the odd-row layout. The provider's fixpoint iteration over the projected dimensions is unchanged. -The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers; the pixel budget the harness applies before pricing (`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`, 640,000 total pixels) and the catalog model ids are unchanged. +The test vectors are re-pinned from the published calculator. The request-pricing tests, package README, and this note carry the new numbers. This change left the 640,000 total-pixel projection the harness applied before pricing and the catalog model ids as they were; the [successor](2026-09-10-deepseek-v41-request-image-projection.md) later replaced that projection with the same grid, so omitting `imagePixelBudget` now projects onto the token grid while a positive integer or `low` keeps a total-pixel budget. ## Alternatives considered @@ -24,4 +24,4 @@ The test vectors are re-pinned from the published calculator. The request-pricin ## Consequences -An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The 640,000-pixel budget does not imply a 422-token ceiling: an 8192×1 image stays within that pixel budget and costs 1024 tokens. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected. +An 800×800 request image costs 422 tokens instead of 349, while a 640×480 image costs 206 instead of 209 and a low-budget 512×512 image costs 184 instead of 201. Compaction pressure changes with the retained image dimensions. The request projection later moved onto the same grid ([successor](2026-09-10-deepseek-v41-request-image-projection.md)), so the 640,000-pixel budget that made a square request image cost 422 tokens no longer applies. The estimate no longer carries a three-token conservative margin; provider usage remains the authoritative anchor once a request completes. Sessions replayed through `llm-replay` use their fixture's `imageRequestTokens` and are unaffected. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md index 6be386da1c..a483282c6d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md @@ -12,7 +12,7 @@ Status: implemented `image-tokens.ts` 重写为 `v41` 配置的逐句移植。常量为 14px patch、每轴 3:1 降采样、544×544 总像素下限、1024 token 上限。网格公式为 `rows × (cols + 1) + 2`,没有奇数行额外行、没有奇偶校正、求解器也不再把行数截成偶数。没有对齐 pad,所以估算值是精确值而非最坏情况上界;没有宽高比钳制,所以极端长宽比会经求解器的单行和单列分支到达上限。超预算路径是一次闭式求解加上公开的断言;逐步递减的重试循环只服务于奇数行布局。提供方对投影尺寸的定点迭代保持不变。 -测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字;harness 在定价前应用的像素预算(`DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET`,640,000 总像素)和 catalog 模型 id 不变。 +测试向量按公开计算器重新固定。request-pricing 测试、包 README 和本 note 使用新数字。本次改动保留了 harness 在定价前应用的 640,000 总像素投影和 catalog 模型 id;[后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)把该投影换成了同一套网格,现在省略 `imagePixelBudget` 走 token 网格,正整数或 `low` 仍走总像素预算。 ## 备选方案 @@ -24,4 +24,4 @@ Status: implemented ## 后果 -800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。640,000 像素预算不意味着 422 token 上限:8192×1 图片在该像素预算内,仍计 1024 token。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。 +800×800 请求图片的计价从 349 变为 422 token,640×480 图片从 209 变为 206,低预算下的 512×512 图片从 201 变为 184。压缩压力随保留图片的尺寸变化。请求投影后来改为同一套网格([后续决策](2026-09-10-deepseek-v41-request-image-projection.zh.md)),让正方形请求图片计 422 token 的 640,000 像素预算已不再适用。估算值不再带 3 token 的保守余量;请求完成后,提供方 usage 仍是权威锚点。经 `llm-replay` 回放的会话使用各自 fixture 的 `imageRequestTokens`,不受影响。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml new file mode 100644 index 0000000000..ece05b1229 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md +2026-09-10-deepseek-v41-request-image-projection.md: 2bf7ac3f7f09ff7aea1067030f4474306d3f9a8e +2026-09-10-deepseek-v41-request-image-projection.zh.md: 4f0a8cfd3a71d54a54bf74d6819235ed7d0749f8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md new file mode 100644 index 0000000000..2bf7ac3f7f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.md @@ -0,0 +1,31 @@ +# Agent Note: DeepSeek request images on the published token grid + +Status: implemented + +English | [中文](2026-09-10-deepseek-v41-request-image-projection.zh.md) + +## Problem + +The harness projected every DeepSeek request image under a 640,000 total-pixel budget, a value chosen for the retired V4 vision model and kept unchanged when the [token estimator moved to the `v41` calculator](2026-09-10-deepseek-image-token-calculator-v41.md). The current Flash model retains far more: it pads each edge to whole 14px patches, groups 3×3 patches into one token cell, and keeps the largest aspect-preserving grid whose token count `rows × (columns + 1) + 2` fits 1024. A square image keeps 1302×1302 pixels, a 16:9 image keeps a 1708×966 grid, and extreme aspect ratios keep up to about 1.8 million pixels. The 640,000-pixel projection therefore sent a 2000×2000 screenshot as 800×800, roughly 38% of the pixels the model would have used, and the estimator priced that reduced version at 422 tokens instead of the 994 the model charges for the full grid. The Vision guide's "about 1300×1300 total pixels" describes only the square case; the exact rule is the token grid. + +Two smaller gaps sat beside it. The request version had no per-side cap: the provider rejects any image over 4096 pixels per side once a request carries 15 or more images, while normalization admits an 8192-pixel long edge, so a many-image session could fail on one thin image. The 1 MiB encoded-byte target was sized for 640,000-pixel outputs and would push a 1302×1302 photograph down the JPEG quality ladder. + +## Decision + +The route chooses each request image's dimensions; the attachment provider only resizes and encodes to them. `ImageRequestPolicy` in `dsh-attachment` becomes `ImageRequestTarget`: a width, a height, and the byte target for one attachment. `readImageRequest` resizes by the source long edge alone without enlargement, so the encoder derives the short edge as the route predicted, and keys its cache by the attachment id, target dimensions, byte target, encoder settings, and the new `request-image-v6` transform version, so no earlier cache entry or upload mapping is reused. `dsh-attachment` keeps two provider-neutral geometry exports: `requestImageDimensions` for a total-pixel budget and `longEdgeDimensions` for an exact long edge with a rounded short edge. + +`llm-deepseek` owns the provider rule. `image-tokens.ts` keeps the verbatim `v41` solver and adds `deepSeekRequestImageDimensions`: the source itself when its patch-padded grid fits the cap, otherwise the source aspect ratio at the solved grid's long edge, so a 3840×2160 source is sent as 1708×961 and the provider pads it to its 1708×966 grid. `resolveRequestImageTarget` applies that solver when `imagePixelBudget` is omitted, `requestImageDimensions` for a positive integer or the 512×512 `low` preset, then a 4096-pixel per-side cap on every request image so the image count never changes a target, and the route's 2 MiB byte target. Pricing prices `deepSeekImageTokens` of the same target, so the estimator and the sent image come from one solver. The pi-ai route derives its targets from its unchanged 2048×2048 pixel budget. Small images are never enlarged because the provider scales up below 544×544 pixels itself. + +## Alternatives considered + +**Raise the pixel budget to 1302×1302.** A total-pixel budget is right only for squares: a 16:9 source would be sent at 1.69 million pixels when the grid keeps 1.65 million, and a 4:1 source when it keeps 1.59 million, while extreme ratios would lose detail the grid keeps. One rule that reproduces the provider removes the guesswork. + +**A `token-grid` projection kind on the attachment policy, with the solver in `dsh-attachment`.** This was built first: the policy became a closed union of `pixel-budget` and `token-grid`, the solver moved into `request-projection.ts`, and `deepSeekImageTokens` imported it back. It put one provider's layout formula and patch constants into the provider-neutral package under a generic-looking name, needed an `unscaled` flag so the store could tell "send the source" from "send the solved size", and would grow a new union member for every provider rule. Handing the store a finished target keeps the provider rule beside the provider's pricing and leaves the store with no projection vocabulary at all. + +**Send the solver's exact grid dimensions with a fill resize.** The solved grid edges are whole patches and differ from the source aspect ratio by under one patch. Filling that box would distort the image slightly even though the provider does the same on its side; preserving the source aspect ratio can change how many token cells the rounded short edge covers. A 1224×1429 source is sent as 1187×1386: the published calculator gives 959 tokens for the source and 992 for the sent dimensions. Request generation and pricing share the target dimensions, and pricing applies the published calculator to that target. + +**Keep the 1 MiB target.** The target is not a cap: an output over it is still sent at the smallest ladder quality. At 1302×1302 a JPEG photograph at quality 85 lands between 400 KB and 1.2 MB, so 2 MiB keeps most images at the top quality and lets more PNG screenshots pass through losslessly, while the inline base64 fallback still holds about seven such images under its 20 MiB bound. + +## Consequences + +A square source now reaches the model at up to 1302×1302 pixels and 994 tokens instead of 800×800 and 422, so image-heavy sessions reach compaction pressure sooner and the estimator applies the published token rules to the sent target dimensions. Every existing request-image cache entry and DeepSeek Files API mapping is regenerated on the next request. Thin images keep their full grid until the per-side cap applies: an 8192×78 source costs 396 tokens under the grid but is sent as 4096×39. `llm-replay` does not project images, so keyless snapshots cannot record the sent dimensions; the `llm-deepseek` adapter tests pin the resolved targets and the projected handle text against a mock server, and the local store tests resize real images to targets. diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md new file mode 100644 index 0000000000..4f0a8cfd3a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-deepseek-v41-request-image-projection.zh.md @@ -0,0 +1,31 @@ +# Agent Note: DeepSeek 请求图片按官方 token 网格投影 + +Status: implemented + +[English](2026-09-10-deepseek-v41-request-image-projection.md) | 中文 + +## 问题 + +harness 此前把每张 DeepSeek 请求图片投影到 640,000 总像素预算内。这个值是为已下线的 V4 视觉模型选的,[token 预估器改用 `v41` 计算器](2026-09-10-deepseek-image-token-calculator-v41.zh.md)时没有改动它。当前 Flash 模型保留的远多于此:它把每条边补齐到整数个 14 px patch,把 3×3 个 patch 归为一个 token 格,再保留 token 数 `rows × (columns + 1) + 2` 不超过 1024 的最大等比网格。正方形图片保留 1302×1302 像素,16:9 图片保留 1708×966 的网格,极端宽高比最多保留约 180 万像素。因此 640,000 像素投影把一张 2000×2000 的截图缩成 800×800 发出,只有模型本可使用像素的约 38%,预估器为这个缩小版计 422 token,而模型对完整网格收 994 token。图像理解指南里的「约 1300×1300 总像素」只描述正方形的情况,确切规则是 token 网格。 + +旁边还有两个较小的缺口。请求版本没有单边上限:请求包含 15 张及以上图片时,提供方拒绝任何单边超过 4096 像素的图片,而规范化允许 8192 像素长边,多图会话可能因一张细长图失败。1 MiB 编码字节目标是按 640,000 像素输出定的,会把 1302×1302 的照片压到 JPEG 质量阶梯的低档。 + +## 决策 + +请求图片的尺寸由路由决定,附件提供方只负责缩放和编码。`dsh-attachment` 里的 `ImageRequestPolicy` 改为 `ImageRequestTarget`,即一张附件的目标宽、高和字节目标。`readImageRequest` 只按源图长边缩放且不放大,编码器按路由预测的方式推出短边;缓存按附件 id、目标尺寸、字节目标、编码参数和新的 `request-image-v6` 变换版本取键,因此之前的缓存条目和上传映射都不会被复用。`dsh-attachment` 保留两个提供方无关的几何导出:按总像素预算的 `requestImageDimensions`,以及长边精确、短边四舍五入的 `longEdgeDimensions`。 + +提供方规则归 `llm-deepseek`。`image-tokens.ts` 保留逐字移植的 `v41` 求解器,并新增 `deepSeekRequestImageDimensions`:补齐 patch 后的网格在上限内就发源图本身,否则按源图宽高比取求解网格的长边,于是 3840×2160 的源图以 1708×961 发送,提供方再把它补齐到 1708×966 的网格。`resolveRequestImageTarget` 在省略 `imagePixelBudget` 时用这个求解器,正整数或 512×512 的 `low` 预设用 `requestImageDimensions`,然后对每张请求图片加 4096 像素单边上限,使图片数量不会改变目标,最后带上路由的 2 MiB 字节目标。计价对同一个目标算 `deepSeekImageTokens`,预估器和发出的图片来自同一个求解器。pi-ai 路由从它不变的 2048×2048 像素预算推导目标。小图不放大,因为提供方自己会放大 544×544 像素以下的图片。 + +## 备选方案 + +**把像素预算提高到 1302×1302。** 总像素预算只对正方形正确:16:9 的源图会按 169 万像素发送而网格只保留 165 万,4:1 的源图网格只保留 159 万,极端比例又会丢掉网格本会保留的细节。一条复现提供方的规则消除了猜测。 + +**在附件策略上加 `token-grid` 投影种类,求解器放进 `dsh-attachment`。** 最初就是这样做的:策略变成 `pixel-budget` 和 `token-grid` 的封闭联合,求解器搬进 `request-projection.ts`,`deepSeekImageTokens` 再从那里引回来。这把一家提供方的布局公式和 patch 常量放进了提供方无关的包,还起了个看似通用的名字;存储层需要一个 `unscaled` 标志来区分「发源图」和「发求解尺寸」;以后每多一家提供方规则,联合就要多长一个分支。把算好的目标交给存储层,提供方规则和它的计价放在一起,存储层不需要任何投影词汇。 + +**用填充缩放发送求解器的精确网格尺寸。** 求解出的网格边长是整数个 patch,与源图宽高比相差不到一个 patch。填充到这个框会轻微变形,尽管提供方那侧也会这样做;保持源图宽高比可能改变取整后短边覆盖的 token 格数。1224×1429 的源图以 1187×1386 发送,官方计算器对源图计 959 token,对发送尺寸计 992 token。请求生成和定价共享目标尺寸,定价按目标尺寸应用官方计算规则。 + +**保留 1 MiB 目标。** 目标不是上限:超过它的输出仍会以阶梯最小质量发送。1302×1302 的 JPEG 照片在质量 85 时约 400 KB 到 1.2 MB,2 MiB 让多数图片停在最高质量,也让更多 PNG 截图无损直发,而内联 base64 回退在 20 MiB 上界内仍能容纳约七张这样的图片。 + +## 后果 + +正方形源图现在最多以 1302×1302 像素、994 token 到达模型,而不是 800×800 和 422,因此图片密集的会话更早触及 compaction 压力,预估器按发送目标尺寸应用官方 token 计算规则。所有已有的请求图片缓存条目和 DeepSeek Files API 映射在下次请求时重新生成。细长图在单边上限生效前保留完整网格:8192×78 的源图在网格下计 396 token,但以 4096×39 发送。`llm-replay` 不投影图片,keyless 快照记录不到发送尺寸;`llm-deepseek` 适配器测试对着 mock 服务器固定了解析出的目标和投影后的句柄文本,本地存储测试把真实图片缩放到目标尺寸。 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml new file mode 100644 index 0000000000..a42e0ee0bd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md +2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md new file mode 100644 index 0000000000..3cccb3c038 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md @@ -0,0 +1,43 @@ +# Agent Note: Composer menu sections, localized rows, and the File row + +Status: implemented + +English | [中文](2026-09-08-composer-menu-sections-and-localized-rows.zh.md) + +## Problem + +The composer's `+` button and a typed `/` listed every command in Host registration order as `name description`, all lowercase, with no glyphs and no grouping, beside a separate paperclip button for files. Under Chinese the rows stayed English because Host descriptors carry English text only, and a user who knew a command by its Chinese title could neither find it by that title nor see what to type. Issue #3567 and the design doc for it ask for two sections in usage order, a glyph and a left-aligned title per row with the description right-aligned, capitalized English titles, Chinese titles and descriptions that stay searchable in both languages and show the English command name, a Chinese fill for the Plan and Goal claims, and a File entry inside the menu. + +## Decision + +`ui-commands` owns the menu's presentation in `src/client/presentation.ts`: an Add section (`file`, `goal`, `plan`, `feedback`) and a Commands section (`compact`, `permission`, `model`, `export`), each in usage order, with rows outside both lists closing Commands in catalog order. The section lists are keyed by name; an empty query returns the sectioned rows, and a typed query returns the flat ranking of every visible row so the best match is always first. + +The six built-in Host commands get their localized title, description, glyph, and claim token from the client. Stable identity selection and input resolution follow the [command identity decision](../architecture/2026-09-10-command-identities-and-composer-file-action.md). Contributions carry `label()`, `description()`, and `icon`, read on every candidate pass, so the `/model` row localizes without re-registration. + +A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/计划 ` and the submission executes `/plan `. A typed token keeps its typed spelling as the claim, because the composer reads the arguments after the token it holds. The effective Session catalog resolves Chinese and English aliases through the same input path in every locale. + +The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands". + +`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. + +The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. + +## Alternatives considered + +**Release a claim when its separator is deleted.** The complete command name still identifies the selected command. Keeping the claim until the name changes preserves its highlight through argument replacement and avoids relying on an IME-generated space to run ordinary keydown adjudication. The shared input machine applies this rule to every command token, including failure recovery; neither the command name nor the locale selects a separate implementation. + +**Restore placeholders directly on native composition end.** Browsers can deliver that event before Lexical reconciles the final text. The shared editor binding keeps command hints and ordinary placeholders hidden while either native or editor composition remains active, and reevaluates visibility after an editor commit, including a cancellation that changes no text. Keyboard submit guards retain their separate post-composition window. + +**Localize descriptions on the Host.** The Host has no locale and its catalog is shared by every client; localized product copy belongs to the client. + +**Keep sections under a typed query.** Ranking inside sections put a prefix hit in Commands below weaker matches in Add (typing `e` listed Feedback and File above Export); a flat ranking keeps the best match first, and the headings only carry information while the list is complete. + +**Keep the paperclip beside the menu.** The design doc and the issue's acceptance criteria integrate the entry into the menu and forbid the old icon from showing twice; the hidden file input and the drop gate stay where they were. + +**A contribution-owned copy for the built-in Host rows.** Host packages have no client half to register from, so the copy has to live on the client; one table keyed by name in the package that already owns the `/` source keeps the design decision in one place. + +**A `token` alias only under the active locale.** Resolving through every dictionary costs nothing and lets a draft persisted under one locale submit under another. + +## Consequences + +The menu and localized presentation remain client-owned, while the Host owns effective command definitions. Adding a localized first-party command requires its identity mapping, dictionary entries, icon, and menu position. Session-driven browser scenarios and owner-local ARIA expectations cover the menu. Unrelated same-name definitions keep their own copy and no first-party glyph. The command identity decision owns this distinction and the File action's lifecycle. diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md new file mode 100644 index 0000000000..31c879b17a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Composer 菜单分节、本地化行与「文件」行 + +Status: implemented + +[English](2026-09-08-composer-menu-sections-and-localized-rows.md) | 中文 + +## 问题 + +composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列成 `name description`,全部小写,没有图标也不分组,旁边还有一个单独的回形针按钮用于添加文件。中文界面下各行仍是英文,因为宿主描述符只携带英文文案;只知道命令中文名的用户既搜不到它,也看不出该输入什么。Issue #3567 及其设计稿要求:按使用频次分成两个小节,每行带图标、标题左对齐、说明右对齐,英文标题首字母大写,中文标题与说明在两种语言下都能搜索并同时显示英文命令名,计划与目标的声明用中文填入,以及把「文件」入口放进菜单。 + +## 决策 + +`ui-commands` 在 `src/client/presentation.ts` 里拥有菜单的展示:「添加」小节(`file`、`goal`、`plan`、`feedback`)与「指令」小节(`compact`、`permission`、`model`、`export`),各按使用频次排列,不在两个清单里的行按目录顺序排在「指令」末尾。小节清单按名字索引;空查询返回分节的行,输入查询后返回所有可见行的平铺排序,最佳匹配始终在第一位。 + +六个内置宿主命令从客户端取得本地化标题、说明、图标和认领 token。稳定标识选择与输入解析遵循[命令标识决策](../architecture/2026-09-10-command-identities-and-composer-file-action.zh.md)。贡献项携带 `label()`、`description()` 和 `icon`,每次生成候选项时读取,因此 `/model` 行无需重新注册即可本地化。 + +菜单选中填入当前语言的认领 token:中文下选中「计划」填入 `/计划 `,提交时执行 `/plan `。手输 token 保留原写法作为认领,因为输入框从它持有的 token 之后读取参数。会话有效目录在所有界面语言下通过同一输入路径解析中英文别名。 + +「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。 + +`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 + +菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 + +## 考虑过的替代方案 + +**删除分隔空格时释放命令认领。** 完整命令名仍能标识已选命令。保留认领直到命令名改变,可以在替换参数时保持高亮,也无需依赖输入法生成的空格触发普通按键裁决。共享输入状态机对所有命令 token 使用这个规则,提交失败后的恢复也相同;命令名和界面语言都不选择另一套实现。 + +**在原生组合输入结束事件中直接恢复占位文字。** 浏览器可能先发送该事件,Lexical 随后才完成最终文字更新。共享编辑器绑定在原生输入法或编辑器仍处于组合输入时隐藏命令提示和普通占位文字,并在编辑器提交更新后重新判断显隐,包括没有文字变化的取消操作。键盘提交保护保留独立的组合输入结束后保护时段。 + +**在宿主侧本地化说明。** 宿主没有语言设置,目录由所有客户端共享,本地化产品文案属于客户端。 + +**输入查询后保留小节。** 在小节内排序会把「指令」里的前缀命中排在「添加」里较弱的匹配之下(输入 `e` 时反馈与文件排在导出之上);平铺排序让最佳匹配始终靠前,小节标题只在列表完整时才有信息量。 + +**在菜单旁保留回形针。** 设计稿与 issue 的验收条件都要求把入口整合进菜单且旧图标不再重复显示;隐藏的文件输入框与拖放门槛保持原位。 + +**由贡献项拥有内置宿主行的文案。** 宿主包没有客户端一半可供注册,文案只能放在客户端;在已经拥有 `/` source 的包里用一张按名字索引的表,能把设计决策集中在一处。 + +**只在当前语言下解析 `token` 别名。** 遍历所有词典解析没有成本,还能让在一种语言下持久化的草稿在另一种语言下提交。 + +## 后果 + +菜单与本地化展示由客户端负责,宿主负责有效命令定义。新增本地化内置命令需要对应的标识映射、词典条目、图标和菜单位置。会话驱动的浏览器场景与归属模块的 ARIA 预期覆盖菜单。无关的同名定义保留自己的文案,不获得内置图标。命令标识决策负责这一划分和文件动作的生命周期。 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml index 3d3ce530c3..16c17f3405 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md -2026-09-08-feedback-dialog-and-categories.md: 02a41f08541cca85dad7cd3c2c984b5e7cd8fd6d -2026-09-08-feedback-dialog-and-categories.zh.md: 40d83360e52bff569120820b78959a7cc3f4db4f +2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31 +2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md index 02a41f0854..ee656946a9 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -12,9 +12,9 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe `command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit. -`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it. +`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor. -The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code. +The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure. ## Alternatives considered @@ -24,9 +24,9 @@ The dialog is the shared Modal card at the design's width; the design's checkbox **Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs. -**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike. +**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves both message-rating paths and the Session dialog. -**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition. +**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; the File row uses the same `action` kind, so one definition serves both entries. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md index 40d83360e5..ef2b2d4670 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -12,9 +12,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` `command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。 -`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回。 +`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。 -弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开。 +弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。 ## 考虑过的替代方案 @@ -24,9 +24,9 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` **在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。 -**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径。 +**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务两种消息评分路径与 Session 弹窗。 -**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可。 +**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;「文件」行使用同一个 `action` 种类,一份定义即可服务两个条目。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf23295de5..bf3876efca 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: f8c87cb3259f757483a6658d79306d3f02d90488 -2026-09-08-shared-file-type-icons.zh.md: ea115cd63b3144ded2c99f818bb9364eea8adfef +2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f +2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index f8c87cb325..1e3fe72679 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -14,11 +14,11 @@ Client feature plugins can share React components only through `@deepseek-ai/dsh `FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. `LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards also use `FileTypeIcon` at 28px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. ## Alternatives considered @@ -32,12 +32,13 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, every supplied technology SVG, instance-safe gradient ids, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences - Client packages use one filename parser and one detailed file-type table instead of importing or recreating feature-local logic. - A new suffix joins the detailed table only when an existing glyph truthfully represents it. If its link category differs from the current adapter, the change must also decide whether the 14px link appearance changes. - Code and configuration artwork preserves its embedded palette and does not accept the traditional `--dsh-file-type-icon-color` override. +- The fixed 48-entry artwork table adds about 35 kB uncompressed and 17 kB gzip to the shared browser bundle; adding categories must justify that static baseline cost. - The primitive owns no copy but does own the default file-type palette. Consumers continue to own accessible labels and surrounding text, and may replace the category color through `--dsh-file-type-icon-color`. - The detailed category names describe presentation, not MIME validation. A suffix is a display hint and does not establish file contents or trust. diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index ea115cd63b..fd1f38ebb3 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -14,11 +14,11 @@ Status: implemented `FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 `LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片也使用 28px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 ## 备选方案 @@ -32,12 +32,13 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、每一份技术 SVG、实例安全的渐变 id、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 - 客户端包使用一个文件名解析器与一份精细文件类型表,不再 import 或重新实现功能包本地逻辑。 - 新后缀只在已有图形能准确表达它时加入精细表。若它的链接类别与当前适配不同,这次改动还必须决定是否改变 14px 链接外观。 - 代码与配置图稿保留内嵌调色板,不接受传统图形的 `--dsh-file-type-icon-color` 覆盖。 +- 固定的 48 项图稿表为共享浏览器 bundle 增加约 35 kB 未压缩体积和 17 kB gzip 体积;新增类别必须证明这份静态基线成本是必要的。 - primitive 不拥有文案,但拥有默认文件类型调色板。消费方继续拥有无障碍 label 与周围文字,并可通过 `--dsh-file-type-icon-color` 替换分类颜色。 - 精细类别名称描述展示意图,不是 MIME 校验。后缀只是展示提示,不能证明文件内容或可信度。 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml new file mode 100644 index 0000000000..7d4eb35fa1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md +2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b +2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md new file mode 100644 index 0000000000..3b6b4c7fd0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md @@ -0,0 +1,29 @@ +# Agent Note: Connection indicator state and interaction refinements + +Status: implemented + +English | [中文](2026-09-10-connection-indicator-refinements.zh.md) + +## Problem + +The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition. + +## Decision + +**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. + +**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration. + +## Alternatives considered + +**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them. + +**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra. + +**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates. + +**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label. + +## Consequences + +`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction. diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md new file mode 100644 index 0000000000..0bd5dc3e0b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 连接指示器状态与交互细化 + +Status: implemented + +[English](2026-09-10-connection-indicator-refinements.md) | 中文 + +## Problem + +侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。 + +## Decision + +**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 + +**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。 + +## Alternatives considered + +**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。 + +**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。 + +**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。 + +**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。 + +## Consequences + +`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。 diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml new file mode 100644 index 0000000000..bc63e89dd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md +2026-09-10-symmetric-message-feedback-submission.md: 51c754dbd3ecc566e15ec1b7a276fc2a3cf7c551 +2026-09-10-symmetric-message-feedback-submission.zh.md: 0ba5d1a1c53f08b76c5bbc32b84687c3dcd38ec2 diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md new file mode 100644 index 0000000000..51c754dbd3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.md @@ -0,0 +1,25 @@ +# Agent Note: Symmetric message feedback submission + +Status: implemented + +English | [中文](2026-09-10-symmetric-message-feedback-submission.zh.md) + +## Problem + +The assistant-message rating controls used different commit points. Like recorded a positive rating immediately, while Dislike opened the feedback dialog and recorded only after Submit. The asymmetry made an accidental Like durable before confirmation and prevented positive feedback from carrying the same optional category and description as negative feedback. + +## Decision + +The [feedback dialog and categories](2026-09-08-feedback-dialog-and-categories.md) decision owns the shared form and durable taxonomy. Both unrecorded ratings open the shared feedback dialog and record only after Submit. A message `FeedbackDialogTarget` carries the selected `positive` or `negative` rating, and `FeedbackSurface` passes that rating with the dialog entry to `MessageFeedbackController.rate`. The action row reads the committed item before either action: clicking its current rating calls the injected `retract` operation, while clicking an absent or opposite rating opens the dialog. `retract` rechecks the committed rating inside the controller's serialized mutation queue and becomes a no-op after a concurrent change, so it cannot turn stale UI intent into a bare rating put. The dialog remains optional-input: submitting without a category or description records the selected rating and raises the acknowledgement toast; dismissing it records nothing. + +## Alternatives considered + +**Keep Like as an immediate action.** This preserves one fewer click for positive feedback, but keeps two submission models beside each other and prevents positive reports from carrying context. + +**Require the dialog to retract a recorded rating.** Retraction has no category or description to collect, and an extra confirmation would make the existing undo action less direct. + +**Create separate positive and negative forms.** The fields, validation, failures, and acknowledgement are identical; carrying the rating in the existing target keeps one draft and submission lifecycle. + +## Consequences + +Neither rating creates a `feedback/message-put` event until the user submits the dialog. Positive and negative records can both include a category and note, while clicking the active rating continues to create `feedback/message-delete` without opening the dialog. Unit coverage pins the shared action path and target routing, and the keyless Web scenarios submit both ratings through the real dialog before checking durable events and telemetry release. diff --git a/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md new file mode 100644 index 0000000000..0ba5d1a1c5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-10-symmetric-message-feedback-submission.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 对称消息反馈提交 + +Status: implemented + +[English](2026-09-10-symmetric-message-feedback-submission.md) | 中文 + +## 问题 + +助手消息的两个评分控件使用不同的提交时点。点赞会立即记录正面评分,点踩则先打开反馈弹窗,仅在用户提交后记录。这种不对称会让误触点赞在确认前就持久化,也让正面反馈无法携带与负面反馈相同的可选分类和描述。 + +## 决策 + +[反馈弹窗与分类](2026-09-08-feedback-dialog-and-categories.zh.md)决策负责共用表单和持久化分类表。两种未记录的评分都打开共用反馈弹窗,并且只在提交后记录。消息 `FeedbackDialogTarget` 携带所选的 `positive` 或 `negative` 评分,`FeedbackSurface` 将该评分与弹窗条目一起传给 `MessageFeedbackController.rate`。动作行会在任一操作前读取已提交条目:点击当前评分会调用注入的 `retract` 操作,点击未记录或相反评分则打开弹窗。`retract` 会在控制器的串行变更队列内重新检查已提交评分,并在并发变更后变为无操作,因此不会把陈旧的 UI 意图转成裸评分 put。弹窗的输入仍可全部留空:不选分类也不填描述时提交会记录所选评分并弹出确认 toast;关闭弹窗不会记录任何内容。 + +## 考虑过的替代方案 + +**保留点赞立即提交。** 这能让正面反馈少一次点击,但会让并列的两个评分继续使用不同提交模式,也无法让正面反馈携带上下文。 + +**撤回已记录评分也必须经过弹窗。** 撤回没有需要收集的分类或描述,多一次确认会让现有撤销操作变得不够直接。 + +**分别创建正面和负面表单。** 两者的字段、校验、失败处理与确认完全相同;在现有目标中携带评分即可共用一套草稿与提交生命周期。 + +## 后果 + +用户提交弹窗前,两种评分都不会创建 `feedback/message-put` 事件。正面与负面记录都可以包含分类和备注,而点击当前评分仍会直接创建 `feedback/message-delete`,无需打开弹窗。单元测试固定共用动作路径与目标路由,无密钥 Web 场景则通过真实弹窗提交两种评分,再检查持久事件与遥测投递。 diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml new file mode 100644 index 0000000000..bd4f67275d --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md +2026-09-09-parallel-macos-notarization.md: 152da4661207bf130389e600c16398335fd9fe70 +2026-09-09-parallel-macos-notarization.zh.md: d5375e39a6348b49ffe5bb93c2c056151d5abe75 diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md new file mode 100644 index 0000000000..152da46612 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.md @@ -0,0 +1,37 @@ +# Agent Note: Parallel macOS notarization from isolated App copies + +Status: implemented + +English | [中文](2026-09-09-parallel-macos-notarization.zh.md) + +## Problem + +The Desktop release distributes a DMG for installation and a ZIP for updates. Waiting for App notarization before creating the DMG serializes two Apple submissions. A proxy improves upload throughput but does not overlap the independent service waits. Stapling modifies the App, so concurrent notarization and packaging cannot safely share that writable directory. + +## Decision + +The fixed-target installer command signs and verifies one App, then creates two independent copies with `ditto`. The App lane notarizes and staples its copy, verifies its signature, ticket, and Gatekeeper acceptance, and asks electron-builder to create the ZIP and updater metadata. The DMG lane immediately packages its copy, signs the image, and uses the existing artifact-completion hook to notarize, staple, and verify the image. Each electron-builder process receives the actual `.app` path through `--prepackaged`, an isolated output directory, and `--publish never`. + +The ZIP contains an individually stapled App. The DMG contains the signed App without an individually stapled ticket; its outer ticket covers the nested code, following Apple's [container guidance](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution). Apple describes [ticket ingestion when Gatekeeper checks the outer container](https://developer.apple.com/forums/thread/125512). Independent extraction of the unstapled App relies on an online or cached ticket; the ZIP supplies an embedded ticket. The directory-only command continues to notarize and staple its App. + +Both lanes settle before error propagation or temporary-directory cleanup. Only two successful lanes allow promotion of the DMG, ZIP, ZIP blockmap, and channel metadata. The stapled App replaces the signed directory build, and the caller writes the release completion record last. An error leaves that record absent, so the existing upload validation rejects the incomplete release. Separate output directories also prevent concurrent writes to electron-builder diagnostics and channel metadata. + +This refines the notarization ordering in the [Desktop packaging decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md); that note remains the owner of release identity, signatures, update ownership, and publishing requirements. + +## Alternatives considered + +**Share one App between both lanes.** A DMG reader can overlap with `stapler` writes, producing a nondeterministic bundle. Independent copies keep the submitted and distributed bytes stable within each lane. + +**Only notarize the DMG.** ZIP updates are distributed independently and need a stapled App. Retaining both submissions keeps that independent qualification explicit. + +**Keep serial notarization and only use a proxy.** The same 214.84 MiB DMG uploads in 203.83 seconds directly and 38.59 seconds through the tested system proxy, but neither route removes the serial dependency between Apple submissions. Proxy configuration remains a build-host concern; the packaging script does not change host network settings. + +**Run both targets in one electron-builder call before App notarization finishes.** The ZIP must read the stapled copy. Separate prepackaged invocations preserve electron-builder's own archive, blockmap, and metadata implementation without changing its target scheduling or patching the dependency. + +## Consequences + +On 2026-09-09, full arm64 packaging on the same Mac through the same system proxy took 490.78 seconds with parallel notarization versus 751.33 seconds serially, a 34.7% reduction. The artifact lanes began 8 milliseconds apart and completed in 307.36 seconds for App/ZIP and 268.54 seconds for DMG. Apple accepted both submissions; their uploads completed about one second apart. Each configuration has one full-build sample, so cache and Apple queue variation prevent attributing the entire difference to concurrency. + +Two temporary App copies and separate artifact directories increase peak disk usage. Two uploads can contend for network bandwidth, and Apple can queue either submission independently; phase timings describe observed behavior rather than a CI latency budget. A failed lane waits for the other lane to finish before cleanup, which can delay failure reporting but avoids deleting files still owned by a child process. + +The [orchestration tests](../../../../apps/desktop/tests/package-macos.spec.ts) use barriers to prove overlap, ticket isolation, both-error collection, and refusal to promote incomplete artifacts. A controlled serial regression fails the overlap assertion. Real signed macOS packaging, extracted ZIP verification, DMG integrity and nested signature checks, and final upload-plan validation qualify the platform tools; cross-version installed updates and offline installation on a clean Mac remain release qualification work. diff --git a/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md new file mode 100644 index 0000000000..d5375e39a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-parallel-macos-notarization.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 基于隔离 App 副本的并行 macOS 公证 + +Status: implemented + +[English](2026-09-09-parallel-macos-notarization.md) | 中文 + +## 问题 + +Desktop 发布同时提供用于安装的 DMG 和用于更新的 ZIP。等待 App 公证完成后才创建 DMG,会让两次 Apple 提交串行执行。代理可以提高上传吞吐量,但不能让两个独立的服务等待过程重叠。钉票会修改 App,因此并发公证和打包不能安全地共享同一个可写目录。 + +## 决策 + +固定目标安装包命令先签名并验证一个 App,再通过 `ditto` 创建两个独立副本。App 路线公证其副本并钉票,验证签名、票据与 Gatekeeper 接受状态,再由 electron-builder 生成 ZIP 和更新元数据。DMG 路线立即封装其副本、签署映像,再通过现有 artifact-completion hook 公证映像、钉票并验证。每个 electron-builder 进程都通过 `--prepackaged` 接收真正的 `.app` 路径、独立的输出目录和 `--publish never`。 + +ZIP 包含已单独钉票的 App。DMG 包含已签名但未单独附加票据的 App;根据 Apple 的[容器说明](https://developer.apple.com/documentation/xcode/packaging-mac-software-for-distribution),外层票据覆盖内嵌代码。Apple 还说明了 [Gatekeeper 检查外层容器时接收票据的行为](https://developer.apple.com/forums/thread/125512)。单独提取未钉票 App 依赖在线或缓存票据;ZIP 则提供内嵌票据。仅生成目录的命令仍会公证 App 并钉票。 + +错误传播和临时目录清理前必须等待两路均结束。只有两路都成功,才允许移入 DMG、ZIP、ZIP blockmap 和频道元数据。已钉票的 App 替换签名目录构建,调用方最后写入发布完成记录。发生错误时该记录保持缺失,现有上传校验因而会拒绝不完整发布。独立输出目录还避免了 electron-builder 诊断文件与频道元数据的并发写入。 + +本决策细化了 [Desktop 打包决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的公证顺序;原决策继续负责发布身份、签名、更新归属与发布要求。 + +## 考虑过的替代方案 + +**两路共享一个 App。** DMG 读取可能与 `stapler` 写入重叠,使 bundle 内容不确定。独立副本使每条路线中提交与分发的字节保持稳定。 + +**只公证 DMG。** ZIP 更新独立分发,需要已钉票的 App。保留两次提交可以明确维持这项独立验收。 + +**保留串行公证,仅使用代理。** 同一个 214.84 MiB DMG 直连上传耗时 203.83 秒,通过所测系统代理上传耗时 38.59 秒,但两种网络路径都不能消除 Apple 提交间的串行依赖。代理配置仍由构建主机负责;打包脚本不修改主机网络设置。 + +**App 公证结束前,在同一次 electron-builder 调用中运行两个目标。** ZIP 必须读取已钉票副本。独立的 prepackaged 调用可以保留 electron-builder 自己的归档、blockmap 和元数据实现,无需修改目标调度或给依赖打补丁。 + +## 影响 + +2026-09-09,在同一台 Mac、同一系统代理下,arm64 完整打包在并行公证时耗时 490.78 秒,串行时耗时 751.33 秒,缩短 34.7%。两条产物路线相隔 8 毫秒启动,App/ZIP 耗时 307.36 秒,DMG 耗时 268.54 秒。Apple 接受了两次提交,两者上传完成时间仅相差约一秒。每种配置只有一次完整构建样本,缓存与 Apple 队列变化使我们不能将全部差值归因于并发。 + +两个临时 App 副本与独立产物目录增加了磁盘峰值占用。两次上传可能争用网络带宽,Apple 也可能分别排队处理;阶段计时记录实际行为,不构成 CI 延迟预算。一路失败后会等待另一路结束再清理,这可能延迟错误报告,但能避免删除仍由子进程持有的文件。 + +[编排测试](../../../../apps/desktop/tests/package-macos.spec.ts)通过同步屏障验证重叠执行、票据隔离、收集两路错误,以及拒绝移入不完整产物。受控的串行回归会使重叠断言失败。真实签名 macOS 打包、ZIP 解压后验证、DMG 完整性与内嵌签名检查、最终上传计划验证用于验收平台工具;跨版本已安装应用更新和干净 Mac 上的离线安装仍属于发布验收工作。 diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml new file mode 100644 index 0000000000..0e0de823e8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md +2026-09-09-nontransactional-loader.md: 7ea4db0abf0fb65e443126dbca9fd822e47ce2d0 +2026-09-09-nontransactional-loader.zh.md: 5293efb58998f68a25e1143a2d043da32ef06bfb diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md new file mode 100644 index 0000000000..7ea4db0abf --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md @@ -0,0 +1,35 @@ +# Agent Note: Keep Loader mutations non-transactional + +Status: implemented + +English | [中文](2026-09-09-nontransactional-loader.zh.md) + +## Problem + +Transactional config reload preserves an old plugin generation after a failed edit, but requires Loader to own candidate imports, lifecycle settlement, rollback, option identity, and Include serialization. These changes make the vendored implementation substantially different from its pinned sources. Application startup and profile patch watching also depend on that settlement implicitly. + +## Decision + +Revert the five commits in [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932), resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins. + +Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree. + +Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. Web startup audits activation before printing a URL or opening the browser. These adaptations preserve existing consumer behavior after the reverse patch. + +Fiber, Entry, and isolate keep their upstream update return behavior. App boot observes discarded restart promises through the existing `internal/update` waterfall and waits for fibers before auditing a patch reload. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. + +Two #932-specific vendor changes remain: awaited initial-file creation and forced rereading in Include, and Schemastery conditional exports. Restoring the pre-#932 debounced write/read sequence reproduces `ENOENT` in the missing-file initialization test. Keeping these two lines preserves the existing `initial` option without an application-side file writer or a second YAML serializer. Removing Schemastery exports reproduces `ERR_REQUIRE_ESM_RACE_CONDITION` while the Web preset suite boots: Node falls back to the CJS entry during concurrent ESM imports. The HMR injection decorators, conditional patch cloning, and update return values use the pre-#932 behavior. Explicit `workspace:^` dependencies make the #932 workspace-link switch and dedicated lockfile check unnecessary. + +## Alternatives considered + +**Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback. + +**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger. + +**Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover. + +## Consequences + +A plugin activation failure can leave the new options and a failed fiber in place. Callers that require active plugins must audit after settlement; awaiting `Loader.create()` alone does not establish activation. Automatic plugin rollback requires a separate future decision with evidence that its recovery benefit warrants the additional lifecycle implementation. + +[Live-patch tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md) retain controlled event delivery and native watcher coverage, while asserting failure reporting without rollback. The [terminal-release policy](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) remains applicable to fatal errors and partial boot teardown. Web preset composition and a real CLI webhook-created model Session provide the application-level verification beyond hand-mounted plugins. diff --git a/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md new file mode 100644 index 0000000000..5293efb589 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 保持 Loader 更改非事务化 + +Status: implemented + +[English](2026-09-09-nontransactional-loader.md) | 中文 + +## 问题 + +事务化配置重载会在编辑失败后保留旧插件代次,但要求 Loader 负责候选导入、生命周期结算、回滚、选项对象身份和 Include 串行化。这些更改使 vendor 实现与固定来源产生显著差异。应用启动和 profile patch 监视也隐式依赖该结算行为。 + +## 决策 + +撤销 [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932) 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。 + +Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。 + +应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。Web 启动在打印 URL 或打开浏览器前检查激活状态。这些适配维持应用反向补丁后已有的消费者行为。 + +Fiber、Entry 和 isolate 保持上游的更新返回行为。App boot 通过现有的 `internal/update` waterfall 观察被丢弃的重启 promise,并在检查 patch 重载前等待 fiber。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。 + +保留两项 #932 专属 vendor 改动:Include 中等待初始文件创建并强制重新读取,以及 Schemastery 条件导出。恢复 #932 前的防抖写入和读取顺序,会在缺失文件初始化测试中复现 `ENOENT`。保留这两行可以维持已有 `initial` 选项,而无需在应用侧增加文件写入器或另一份 YAML 序列化逻辑。移除 Schemastery exports 后,Web preset 测试在启动时复现 `ERR_REQUIRE_ESM_RACE_CONDITION`:并发 ESM 导入使 Node 回退到 CJS 入口。HMR 注入装饰器、条件 patch 克隆及更新返回值采用 #932 前的行为。显式 `workspace:^` 依赖使 #932 的 workspace 链接开关与专用锁文件检查不再必要。 + +## 考虑过的替代方案 + +**保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。 + +**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。 + +**将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。 + +## 后果 + +插件激活失败可能保留新选项和失败的 fiber。要求插件处于激活状态的调用者必须在结算后检查;仅等待 `Loader.create()` 不能证明激活。自动插件回滚需要后续独立决策,并证明其恢复收益值得额外的生命周期实现。 + +[实时 patch 测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md) 保留受控事件投递和原生监视覆盖,同时断言不回滚时的失败报告。[终端释放策略](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) 仍适用于致命错误和部分启动拆卸。Web preset 组合与真实 CLI webhook 创建的模型 Session 提供手动挂载插件之外的应用级验证。 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml new file mode 100644 index 0000000000..e5675ab2e6 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md +2026-09-06-client-assembly-test-line.md: 0d7ed86e4f0c8898441737c8d6a4c631586268a2 +2026-09-06-client-assembly-test-line.zh.md: 8b4381dbfa24819e1b1f086b7c318d388b681ef5 diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md new file mode 100644 index 0000000000..0d7ed86e4f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.md @@ -0,0 +1,90 @@ +# Agent Note: Whole-client test tier over an endpoint-named Remote mock + +Status: implemented + +English | [中文](2026-09-06-client-assembly-test-line.zh.md) + +## Problem + +Browser feature specs each hand-build their bench: a bare Cordis context, stand-ins for `locale`, `connection`, and `remote`, and slot declarations the real declarer would have made. Their assertions therefore describe the bench, not the product: a plugin that adds a settings section, a declarer that reloads through the Loader, or a Connection that reconnects is invisible to them, and every bench repeats the same forty lines with small drift. + +API client specs drive their objects through a programmable fake of the Remote surface. The fake re-implements Gateway semantics it should only exercise: follow-stream opening snapshots derived from a history list, page cuts, stream pumps with delivery promises, and an envelope layer. Each of those is a second implementation of a contract the product already owns, and it lets tests describe behavior the generated client cannot produce, such as a unary call that rejects. + +No focused source test boots the client the way production does — `bootClient` creating one Loader entry per manifest row, then `mountClient` — so composition faults between plugins are hidden by hand-built benches. + +## Decision + +A whole-client tier lives in `@deepseek-ai/dsh-client-test-runtime` under the deep import `src/assembly/`, and a new test-support package `@deepseek-ai/dsh-remote-mock` answers Remote traffic by endpoint name. Both are described by their READMEs ([client-runtime](../../../../packages/test-support/client-runtime/README.md), [remote-mock](../../../../packages/test-support/remote-mock/README.md)); this note records the decisions behind them. + +**The roster is read from the bundles, never copied.** `bundleRoster(bundles)` parses each bundle's `dsh.bundle.patch` with the include plugin's own YAML dialect (`entryListSchema`, which carries `!!js`) and composes the layers with its `applyEntryPatches`, then keeps every enabled row whose package declares `dsh.client.platform === 'web'`, carrying that declaration's `inject` and `immediately`. `webApp` is the `web` profile's roster (`dsh-base`, then `dsh-web-app`), computed at import. A spec names what it tests and derives the rest: `webApp.closure([row])` keeps a row plus its transitive `inject` cone; `pick` and `without` exist for deliberate cuts. The test runtime stays a Client-face package: it imports no Host module, uses no dynamic import for this, and its client-face `types` adds `node` beside `client-build-environment` so the reader can use `node:fs`. `closure` treats the shell's static platform modules (`PLATFORM_MODULES`) as satisfied without a row. + +**The production boot path runs unchanged.** `TestClient.start(plan, mock)` installs the mock as the Connection carrier (`__DSH_TRANSPORT__ = { rpc: mock.rpc }`), loads each roster row's `/client` module, registers its factory through the production module facade's `pendingQueue`, boots through `bootClient`, optionally mounts through `mountClient`, and waits for `connected`. `reload(name)` rebuilds a Loader entry through client-hmr's exported `tearDownEntryFiber`; `unload(name)` removes it; `dispose()` tears down and then fails the test on any endpoint that had no rule. jsdom lacks `EventSource` and `ResizeObserver`; `start` installs inert stand-ins only where the global is absent. Boots and entry rebuilds run one at a time per worker, since the `connection` plugin reads the transport global at apply, and each installs the acting client's transport first; the transport and shims are held by reference count, the first client installing them and the last dispose restoring them, so overlapping clients in one test each connect to their own mock, also after a `reload` of the `connection` row. + +**`remote.` is a contract-free proxy, not the generated client.** The `@deepseek-ai/dsh-api-remotes` row is dropped because its generated clients exist only in built `lib/`. For every `remote.` a roster row injects, plus every namespace the mock has a rule for, the tier provides one Proxy: `ctx.remote..(...args)` calls the endpoint `/` over the roster's own Connection with the positional args, as a stream when the mock registered a stream script for it and as a unary call otherwise. Cordis resolves `ctx.remote.` to the service `remote.`, so the Gateway client itself is untouched. A unary answer returns unchanged; a unary rejection folds the way the generated client folds a carrier throw, through the Gateway client's exported `carrierFailure` and `cancelledFailure`, so product code that fires a Remote call without awaiting sees no rejection. Stream items and failures pass through as the stream yields them. + +**Native mocks own response configuration and call assertions.** Tests use `mock.remote..` with `mockResolvedValue`, `mockResolvedValueOnce`, `mockReturnValueOnce`, or `mockImplementation`; the generated API supplies the signatures. Each mock instance owns its native response queue. Reusable tables register only default values or positional handlers, and each endpoint retains only its latest default. Stateful callbacks and deferred promises belong to individual tests. `ok` builds the success envelope. Streams require explicit declarations and can receive scripts over the opening args and a handle (`push`, `end`, `fail`); a declaration without a script produces a stream miss, while undeclared endpoints default to unary calls. Values are not validated. `mock.streams` controls scripted streams and exposes readiness/drain waits; `mock.log` records carrier calls (`pending`, `answered`, `failed`), scripted-stream state, first-argument `requests(endpoint?)`, and unmatched requests. `RemoteMock.create()` answers `$events` with a ready frame so the client can connect. + +**Vitest owns each test's mock and client lifetime.** `createClientTest(plan, options)` adds native `mock`, `remote`, and `start` fixtures. The mock is fresh and carries the default responses; `remote` is its namespace proxy, and explicit `start()` leaves startup responses configurable and shares one startup promise within the test. Teardown waits for startup, disposes the successful client even after an assertion failure, checks missing responses, and rejects later starts. Callers await startup failures. Independently owned clients still use `TestClient.start`. Scenario data configures native mocks directly; returning a mutation response and updating subsequent describe responses remain separate actions. + +**`remoteDefaultResponses` holds default responses for the boot-time Remote endpoints.** The table lists exactly the endpoints the `web` roster calls while booting and rendering with no sessions, no workspaces, and default settings, each row commented with its caller. A spec layers its own `RemoteTable` on top; a new boot-time call fails the spec at `dispose()`. + +`mock.remote` uses native `@vitest/spy.fn` functions shared by direct callers and Connection dispatch. `MockedRemote` applies Vitest's deep mock type transformation to the entire generated namespace map; an empty map weakens only this proxy to `any`. Production `Context` and Remote declarations remain strict, with no namespace-specific type copies or compiler flags. The [proxy typing guidance](../../../../packages/test-support/remote-mock/README.md#remote-proxy) requires build-backed local type checking even when unbuilt tests pass. + +## Product exports added for the tier + +- `client/connection`: `ClientTransportHooks.rpc?` publishes the already decoded carrier the `?fixture` path used internally; `fetch` becomes optional. +- `client/hmr`: `tearDownEntryFiber(entry)` is the registry-first fiber teardown `reload` already performed. +- `client/modules`: `parseDshClient` and `exactPackageSpecifier` are exported from the client face and shared by the Host and roster reader. Test factories use the existing registration queue. The roster-to-boot-graph synthesis has only test consumers and lives in the tier. +- `client/web`: `bootClient` and `mountClient` are extracted from `AppWebEntry.run()`, which now calls them. +- `api/gateway`: `carrierFailure` and `cancelledFailure` are exported so a stand-in for the generated client folds identically. + +## Alternatives considered + +**Running the generated `/remote` clients from built `lib/`.** Rejected: it makes source-plane specs depend on a build artifact, and the proxies need only the unary-or-stream declaration the mock already holds. + +**A generated static roster module with a drift gate.** Rejected after review: it is a copy of bundle data inside the test package, and every subset written against it is a hand-list that misses rows. Reading the bundles at import through the include plugin's own schema and patch application removes the copy, the generator, and the gate. + +**A Host compile face for the test runtime, a dynamic import of a Host module, or a vitest `globalSetup` handing rosters through `provide`/`inject`.** Rejected: a Client test runtime must not import Host code, dynamic imports hide the dependency, and a config-level channel hides the roster's source. The composition functions the launcher uses are face-neutral, so none of these is needed. + +**A hand-written test-side YAML and patch parser.** Rejected: `entryListSchema` and `applyEntryPatches` are the launcher's own and carry no Host Context merge; the tier writes only file reading, package.json location, and the web-row filter. + +**A mock module standing in for the Gateway client.** Rejected: the mock must not interfere with Gateway internals; installing it on the Connection carrier keeps retry, folding, and stream semantics real. + +**A second typed Gateway implementation with an `Api` generic, envelope and error classes, and a fixtures directory.** Rejected: it duplicates Gateway declarations and encoding. The mock derives method types from the generated namespace map and declares only unary or stream behavior at runtime. + +**Proxies passing unary rejections through unchanged.** Rejected: product code never awaits a Remote rejection because the generated client folds carrier throws, so an unmatched endpoint produced unhandled rejections; folding through the exported helpers restores the client's face. + +**Keeping CallContext and wrapping native spies in an adapter.** Rejected: it makes each test unwrap a synthetic call and retains counter/state machinery with no business-spec consumer. Positional handlers use the existing test ecosystem directly. + +**A separate `once` / `sequence` DSL and fallback rule stack.** Rejected: native per-instance queues already express the deferred responses and temporary failures used by consumers. Immutable table declarations with a cursor per registration would allow shared one-shot tables, but no current shared table requires them. The tier gives up newest-registration-first fallback and table-level repeat-last declarations; tests use native queue order and a persistent default instead. Response values and stateful handlers remain borrowed, not cloned. + +**A separate pre-materialized-module option or `staticModules`.** Rejected: the existing pending registration queue accepts the same factories before Loader startup. `staticModules` bypasses factory materialization and does not share graph prefetch/invalidation behavior; the queue removes the extra option without losing that behavior. Assembly-only helpers remain in their leaf modules rather than the recommended entry's exports. + +**A compiler-wide fallback flag or private augmentation package.** Rejected: declaration merging affects every file in a TypeScript Program that reaches the import; `private: true` only prevents publication. Separate test compiler graphs and additional policy checks add configuration maintenance without narrowing the fallback to its actual helper consumers. A local conditional type limits weakened inference to those consumers. + +**Per-namespace helpers with selected method lists and separate spy aliases.** Rejected: they repeat operation names and controls already supplied by native mocks. The generic proxy derives every method from the production namespace map, while fixtures own the returned data rather than a second implementation of domain writes or publication. + +## Consequences + +Specs boot real plugins: the whole `web` roster costs about five seconds cold and well under a second warm, and a three-row cone about twenty milliseconds per test. Assertions read product facts — the real section list, the real declarer, a Loader rebuild, a second `$events` generation on reconnect — and change when the product changes. + +The proxies skip the generated client's zod validation, wire-name mapping, and scoped-identity injection; mock rules read positional `args`, and the generated clients stay covered by the built-artifact e2e lanes. `remoteDefaultResponses` must gain a row when a plugin adds a boot-time call, and fails loud until it does. The two bundle names of the `web` profile are repeated once in `WEB_PROFILE_BUNDLES`, mirroring the launcher's `PROFILE_TEMPLATES.web`, and no check links the two: the client test program cannot import `@deepseek-ai/dsh-app-boot`, whose Host `Context` merges collide with the Client ones, and the test runtime takes no Host dependency even for tests. A template change therefore has to be carried to that constant by hand. + +The shared functions keep production and test callers on one implementation. `AppWebEntry.run()` mounts the Loader after the immediate-tier prefetch settles; application entry creation remains after prefetch, so serializing Loader setup with prefetch does not advance application activation. + +Native stream overrides may return their own iterable. The caller then owns consumption and cancellation; these iterables bypass scripted-stream logs and controls. Registered scripts retain managed queues and cancellation. This distinction preserves native mock behavior without adding another iterator wrapper or changing pull timing. + +## Deferred + +Product facts the tier surfaced and leaves as they are: + +- No `declare module` augmentation declares `Context.connection`; consumers read `ctx.get('connection') as ConnectionHandle`, and `TestClient.connection` is the typed entry the tier offers. +- `TestClient.start` has no page-URL option, so a spec that needs the `connection` plugin to classify the page as off-loopback reconfigures the jsdom instance vitest exposes on `globalThis.jsdom`, a private detail of the jsdom environment provider. +- `ISessions` exposes no queue observation point, so queue frames reach a `Session` through `handleControlFrame` directly rather than over the `session/control` stream. +- A durable event pushed twice with the same seq is dropped at the tail of `RemoteJournalStream` as a replay and never reaches `SessionQueueMirror.acceptDurable`. +- The vendored Loader rejects `create()` when a module import fails, so the import-failed branch of `assertEntriesActive` is unreachable through `create()`. +- The session-controller client casts `ctx.remote as unknown as SessionRemotes`; in the client test program the cast is redundant, since the generated `/remote` merges are visible there. + +## Testing + +`packages/test-support/remote-mock/tests/` covers rules, streams, the log, and the carrier face; the `assembly-` specs under `packages/test-support/client-runtime/tests/` cover the roster reader on the real bundles and on a scratch installation, module loading, the proxies including their fold, and `TestClient` under jsdom and plain Node. Seven converted specs use the tier. In `packages/client/ui-settings-general/tests/`, the shell and apply specs boot the whole `web` roster; the apply spec reads its Chinese copy from the Host settings document the mock answers and reconfigures the jsdom page URL for the off-loopback branch. In `packages/api/session-controller/tests/`, the Session, queue-store, and pending-submission specs drive their objects over the roster's real Connection through the `remote.` proxies, with the Gateway client's own `$stream` retry loop, over the gateway's dependency cone, and the client-apply spec boots the plugin's dependency cone, delivering Remote events as emit frames on `$events`. In `packages/api/workspace-controller/tests/`, the transport spec boots the plugin's cone for apply cases and the gateway cone for hand-built stream and controller cases, since a rostered plugin would share the follow endpoint. Each package keeps a `tests/remote/` module with its default responses and frame builders. The fixture tests include an expected assertion failure and independently observe completed client cleanup; settings reload tests observe replaced registration identities, and write tests assert every mutation argument. Teardown-failure tests execute the real tree disposer before reporting the injected failure and observe the `$events` stream's cancelled state. diff --git a/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md new file mode 100644 index 0000000000..8b4381dbfa --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-06-client-assembly-test-line.zh.md @@ -0,0 +1,90 @@ +# Agent Note: 基于端点具名 Remote mock 的整机客户端测试档 + +Status: implemented + +[English](2026-09-06-client-assembly-test-line.md) | 中文 + +## 问题 + +浏览器功能 spec 各自手拼测试台:一个裸 Cordis context、`locale`、`connection`、`remote` 的替身,以及本该由真声明者做出的 slot 声明。它们的断言因此描述的是测试台而不是产品:一个插件新增了设置 section、一个声明者经 Loader 重载、一个 Connection 重连,对它们都不可见,而每个测试台都重复着同样的四十行并各有细小漂移。 + +API 客户端 spec 用一个可编程的 Remote 面假件驱动对象。这个假件重新实现了它本该只是调用的 Gateway 语义:从历史列表推导 follow 流的开场快照、切页、带投递 promise 的流泵,以及一层信封。每一样都是产品已有契约的第二份实现,它还让测试描述出生成客户端做不出来的行为,比如一次会 reject 的一元调用。 + +没有聚焦源码测试按生产方式起客户端——`bootClient` 按 manifest 每行建一个 Loader entry,再 `mountClient`——插件之间的组合故障会被手工测试台遮住。 + +## 决定 + +整机档放在 `@deepseek-ai/dsh-client-test-runtime` 的深 import `src/assembly/` 下,新的 test-support 包 `@deepseek-ai/dsh-remote-mock` 按端点名应答 Remote 流量。两者的用法由各自 README 描述([client-runtime](../../../../packages/test-support/client-runtime/README.zh.md)、[remote-mock](../../../../packages/test-support/remote-mock/README.zh.md));本文记录它们背后的决定。 + +**roster 从 bundle 现读,绝不拷贝。** `bundleRoster(bundles)` 用 include 插件自己的 YAML 方言(带 `!!js` 的 `entryListSchema`)解析每个 bundle 的 `dsh.bundle.patch`,用它的 `applyEntryPatches` 合成各层,再保留每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行,带上该声明的 `inject` 与 `immediately`。`webApp` 是 `web` profile 的 roster(先 `dsh-base`、再 `dsh-web-app`),import 时算出。spec 点名它要测的东西,其余推导:`webApp.closure([row])` 保留一行及其传递 `inject` 锥;`pick` 与 `without` 留给刻意裁剪。测试运行时仍是 Client 面的包:它不 import 任何 Host 模块,此处不用动态 import,其 client 面的 `types` 在 `client-build-environment` 之外加了 `node`,好让读取器使用 `node:fs`。`closure` 把 shell 静态种入的平台模块(`PLATFORM_MODULES`)视为无需行即已满足。 + +**生产启动路径原样运行。** `TestClient.start(plan, mock)` 把 mock 装成 Connection 载体(`__DSH_TRANSPORT__ = { rpc: mock.rpc }`),加载每个 roster 行的 `/client` 模块,经生产模块 facade 的 `pendingQueue` 登记其工厂,经 `bootClient` 启动,可选经 `mountClient` 挂载,然后等 `connected`。`reload(name)` 经 client-hmr 导出的 `tearDownEntryFiber` 重建一个 Loader entry;`unload(name)` 移除它;`dispose()` 拆掉一切,然后对任何没有规则的端点让测试失败。jsdom 没有 `EventSource` 与 `ResizeObserver`;`start` 只在全局缺失处装惰性替身。每个 worker 内启动与 entry 重建逐个进行,因为 `connection` 插件在 apply 时读传输全局,每次都先装上当事客户端的传输;传输与替身按引用计数持有,第一个客户端安装、最后一次 dispose 恢复,因此同一测试里重叠的客户端各连各的 mock,`reload` 了 `connection` 行之后也是。 + +**`remote.` 是无契约代理,不是生成客户端。** `@deepseek-ai/dsh-api-remotes` 行被去掉,因为它生成的客户端只存在于构建后的 `lib/`。对 roster 行注入的每个 `remote.`,加上 mock 有规则的每个命名空间,本档各提供一个 Proxy:`ctx.remote..(...args)` 经 roster 自己的 Connection 用位置参数调用端点 `/`,mock 为它登记了流脚本就走流、否则走一元。Cordis 把 `ctx.remote.` 解析到服务 `remote.`,所以 Gateway 客户端本身不动。一元应答原样返回;一元拒绝按生成客户端折叠载体抛错的方式折叠,经 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure`,因此不等待就发出 Remote 调用的产品代码看不到任何 reject。流的项与失败按流吐出的样子直传。 + +**原生 mock 负责响应配置和调用断言。** 测试通过 `mock.remote..` 使用 `mockResolvedValue`、`mockResolvedValueOnce`、`mockReturnValueOnce` 或 `mockImplementation`,签名由生成的 API 提供。每个 mock 实例独立持有原生响应队列。可复用的表只登记默认值或位置参数 handler,每个端点仅保留最新默认响应。有状态回调和 deferred promise 归各测试所有。`ok` 构造成功信封。流需要显式声明,可提供接收打开参数与句柄(`push`、`end`、`fail`)的脚本;无脚本的声明产生流漏配,未声明端点默认走一元。值不校验。`mock.streams` 控制脚本流并提供打开/排空等待;`mock.log` 记录载体调用(`pending`、`answered`、`failed`)、脚本流状态、首参数 `requests(endpoint?)` 和未匹配请求。`RemoteMock.create()` 为 `$events` 应答 ready 帧,让客户端可以连接。 + +**Vitest 拥有每条测试的 mock 和客户端生命周期。** `createClientTest(plan, options)` 增加原生 `mock`、`remote` 与 `start` fixture。mock 每次新建并携带默认响应;`remote` 是它的命名空间 Proxy,显式 `start()` 留出配置启动期应答的时机,同一测试共用一个启动 Promise。收尾等待启动,即使断言失败也销毁成功创建的客户端、检查漏配,并拒绝后续启动。启动错误由调用方 await 观察。分别拥有多个客户端时仍用 `TestClient.start`。场景数据直接配置原生 mock;返回 mutation 应答与更新后续 describe 应答仍是两个独立操作。 + +**`remoteDefaultResponses` 是启动期 Remote 端点的默认响应。** 这张表恰好列出 `web` roster 在没有 session、没有 workspace、默认设置下启动并渲染时会打的端点,每行注明调用方。spec 在其上叠加自己的 `RemoteTable`;新的启动期调用会在 `dispose()` 时让 spec 失败。 + +`mock.remote` 使用直接调用方与 Connection 分发共用的原生 `@vitest/spy.fn` 函数。`MockedRemote` 对完整生成的命名空间映射应用 Vitest 深层 mock 类型转换;映射为空时仅这个 Proxy 弱化为 `any`。生产 `Context` 与 Remote 声明保持严格,不需要命名空间专属类型副本或编译器 Flag。[Proxy 类型指引](../../../../packages/test-support/remote-mock/README.zh.md#remote-proxy)要求即使无构建测试通过,本地也必须执行构建后的类型检查。 + +## 为本档新增的产品导出 + +- `client/connection`:`ClientTransportHooks.rpc?` 公开 `?fixture` 路径内部已在用的已解码载体;`fetch` 变为可选。 +- `client/hmr`:`tearDownEntryFiber(entry)` 就是 `reload` 本来执行的 registry 先行的 fiber 拆除。 +- `client/modules`:`parseDshClient` 与 `exactPackageSpecifier` 从 client 面导出,由 Host 和 roster 读取器共用。测试工厂使用已有注册队列。roster 行到 boot graph 的合成只有测试消费者,放在本档里。 +- `client/web`:`bootClient` 与 `mountClient` 从 `AppWebEntry.run()` 抽出,后者现在调用它们。 +- `api/gateway`:导出 `carrierFailure` 与 `cancelledFailure`,让生成客户端的替身折叠得一模一样。 + +## 考虑过的替代方案 + +**从构建后的 `lib/` 运行生成的 `/remote` 客户端。** 否决:它让源码面的 spec 依赖构建产物,而代理只需要 mock 已持有的一元或流声明。 + +**带漂移门禁的生成静态 roster 模块。** 评审后否决:它是测试包内的一份 bundle 数据拷贝,基于它写的每个子集都是会漏行的手列清单。用 include 插件自己的 schema 与补丁应用在 import 时读 bundle,把拷贝、生成器和门禁一起去掉。 + +**给测试运行时加 Host 编译面、动态 import 一个 Host 模块,或用 vitest `globalSetup` 经 `provide`/`inject` 传 roster。** 否决:Client 测试运行时不得 import Host 代码,动态 import 藏起依赖,配置层通道藏起 roster 的来源。启动器用的合成函数本就面中立,这些都不需要。 + +**自写一套测试侧的 YAML 与补丁解析器。** 否决:`entryListSchema` 与 `applyEntryPatches` 就是启动器自己的,不带 Host Context 合并;本档只写读文件、定位 package.json 和 web 行过滤。 + +**用一个 mock 模块替代 Gateway 客户端。** 否决:mock 不得干涉 Gateway 内部;装在 Connection 载体上让重试、折叠与流语义都保持真实。 + +**第二套带类型的 Gateway 实现,包含 `Api` 泛型、信封与错误类以及 fixtures 目录。** 否决:它重复 Gateway 声明与编解码。mock 从生成的命名空间映射派生方法类型,运行时只声明一元或流行为。 + +**代理把一元拒绝原样直传。** 否决:产品代码从不等待 Remote 的 reject,因为生成客户端会折叠载体抛错,于是没匹配的端点造成未处理的拒绝;经导出的辅助函数折叠恢复了客户端的面。 + +**保留 CallContext,再用适配器包装原生 spy。** 否决:每个测试都要解开合成调用对象,还保留没有业务 spec 消费者的计数/状态机制。位置参数 handler 直接使用现有测试生态。 + +**独立的 `once` / `sequence` DSL 与回退规则栈。** 否决:按实例持有的原生队列已经能表达消费方使用的延迟响应和临时失败。不可变表声明配合每次登记的游标虽能共享一次性响应表,但当前没有共享表需要它。本档放弃最新登记优先的回退和表级末项重复声明,测试改用原生队列顺序与持续默认响应。响应值和有状态 handler 仍按引用借用,不做克隆。 + +**独立的预加载模块选项或 `staticModules`。** 否决:现有待注册队列能在 Loader 启动前接收同样的工厂。`staticModules` 绕过工厂物化,也不共享图的预取/失效行为;队列在保留这些行为的同时省掉额外选项。仅装配内部使用的 helper 保留在叶模块,不从推荐入口再导出。 + +**编译器级全局降级 Flag 或私有类型增补包。** 否决:声明合并会影响同一 TypeScript Program 中能够到达该导入的所有文件;`private: true` 只阻止发布。拆分测试编译图或增加策略检查会增加配置维护成本,却不能把降级限制在真正使用它的 Helper 中。局部条件类型只弱化这些消费方的类型推断。 + +**为每个命名空间编写方法清单和独立 spy 别名的 Helper。** 否决:它们重复操作名称和原生 mock 已经提供的控制功能。通用 Proxy 从生产命名空间映射派生每个方法,fixture 拥有返回数据,而不再实现另一份领域写入或发布机制。 + +## 后果 + +spec 起的是真插件:整个 `web` roster 冷启动约五秒、热启动远低于一秒,三行的锥每例约二十毫秒。断言读的是产品事实——真实的 section 清单、真实的声明者、一次 Loader 重建、重连时的第二代 `$events`——并随产品变化而变化。 + +代理跳过了生成客户端的 zod 校验、wire 字段名映射与 scoped 身份注入;mock 规则读位置 `args`,生成客户端仍由构建产物 e2e 车道覆盖。插件新增启动期调用时 `remoteDefaultResponses` 必须加一行,加之前会响亮失败。`web` profile 的两个 bundle 名在 `WEB_PROFILE_BUNDLES` 里重复了一次,对应启动器的 `PROFILE_TEMPLATES.web`,且两者之间没有机检联系:客户端测试程序不能 import `@deepseek-ai/dsh-app-boot`(其 Host `Context` 合并与 Client 的冲突),而测试运行时连测试也不引入 Host 依赖。模板变更因此要靠人工带到这个常量。 + +共享函数让生产和测试调用方使用同一份实现。`AppWebEntry.run()` 在立即层预取落定后挂载 Loader;应用 entry 仍在预取之后创建,因此让 Loader 安装与预取串行不会提前应用激活。 + +原生流覆盖可以返回自有 iterable,此时调用方负责消费与取消,这些 iterable 不参与脚本流日志或控制。已登记的脚本仍使用受控队列与取消机制。这一区分保留原生 mock 行为,无需再添加 iterator 包装器或改变拉取时机。 + +## 遗留事项 + +本档暴露出来、原样保留的产品事实: + +- 没有任何 `declare module` 增强声明 `Context.connection`;消费方一律 `ctx.get('connection') as ConnectionHandle`,`TestClient.connection` 是本档提供的带类型入口。 +- `TestClient.start` 没有页面 URL 选项,需要 `connection` 插件把页面判为 off-loopback 的 spec 只能重配 vitest 挂在 `globalThis.jsdom` 上的 jsdom 实例,这是 jsdom 环境提供者的私有细节。 +- `ISessions` 没有 queue 观察点,queue 帧只能直接经 `handleControlFrame` 到达 `Session`,而不是走 `session/control` 流。 +- 同 seq 的 durable 事件二次推送在 `RemoteJournalStream` 尾部被当作重放丢弃,到不了 `SessionQueueMirror.acceptDurable`。 +- vendored Loader 在模块 import 失败时让 `create()` reject,因此 `assertEntriesActive` 的 import 失败分支经 `create()` 不可达。 +- session-controller 客户端把 `ctx.remote` cast 成 `SessionRemotes`;在客户端测试程序里这个 cast 是多余的,因为生成的 `/remote` 合并在那里可见。 + +## 测试 + +`packages/test-support/remote-mock/tests/` 覆盖规则、流、日志与载体面;`packages/test-support/client-runtime/tests/` 下的 `assembly-` 系列 spec 覆盖在真 bundle 与临时安装上的 roster 读取器、模块加载、含折叠的代理,以及 jsdom 与纯 Node 下的 `TestClient`。七条改造后的 spec 使用本档。`packages/client/ui-settings-general/tests/` 下,shell 与 apply 两条起整个 `web` roster;apply 从 mock 应答的 Host settings 文档读它的中文文案,并为 off-loopback 分支重配 jsdom 页面 URL。`packages/api/session-controller/tests/` 下,Session、queue-store、pending-submission 三条在 gateway 依赖锥上经 `remote.` 代理走 roster 的真 Connection 驱动对象(`$stream` 的重试循环仍是 Gateway 客户端自己的),client-apply 起插件的依赖锥,把 Remote 事件作为 `$events` 上的 emit 帧投递。`packages/api/workspace-controller/tests/` 下,transport 的 apply 用例起插件锥、手工构造流与 controller 的用例起 gateway 锥,因为进了 roster 的插件会共用 follow 端点。每个包在 `tests/remote/` 保有自己的默认响应与帧构造。fixture 测试包含预期的断言失败,并独立观察客户端清理完成;settings 重载测试观察注册身份被替换,写入测试断言全部 mutation 参数。teardown 失败测试先执行真实树清理,再报告注入的失败,并观察 `$events` 流的取消状态。 diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml index 20015c92fe..ac30e2d61d 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.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/testing/2026-09-06-frontend-performance-budgets.md -2026-09-06-frontend-performance-budgets.md: 4c1a99e10c9b2d7ba7ecbd38f2249ac84e9d330b -2026-09-06-frontend-performance-budgets.zh.md: f563c55e6440de85d71110cbfb2533c7f99218ce +2026-09-06-frontend-performance-budgets.md: 4d69dda8a04d4e9807c18c8f1b978ca7fc87a883 +2026-09-06-frontend-performance-budgets.zh.md: c9a174df55a2c175232eb2f4d8468b054e532b9c diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md index 4c1a99e10c..4d69dda8a0 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md @@ -16,7 +16,7 @@ The existing serial benchmark inventory includes two frontend owners: [active re The browser input contains 240 closed turns, 40 tool results, and 20 code fences, plus mixed-language prose and reasoning. Historical Assistant records carry matching compact streams built through the production accumulator with 12-character reasoning/text deltas and 8-character tool-argument deltas; empty streams would omit stored and transferred payload costs. Nine older-page actions exhaust this input from its observed 25-turn initial window; the readiness probe follows mounted turn growth rather than duplicating the pagination algorithm. Each sample uses a fresh scaffold and browser. Setup, seeding, browser launch, initial shell load, and sidebar expansion are excluded from open timing. Open ends at transcript availability and an editable composer; page and navigation timings end at their target DOM state. Two animation frames include a rendering opportunity, not hardware presentation or a guarantee that every offscreen node painted. -The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups stay inside the latest Assistant step and retain visible-state waits. Diagnostics capture reply markers and focus immediately after the first-visible wait, plus browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak. +The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Enter submission from the focused composer; typing retains focus without a mouse-refocus action or a separate pre-input animation-frame wait. First/final marker lookups sample visible text inside the latest Assistant step on animation frames, avoiding selector retry backoff. Hidden text and markers in older steps cannot satisfy the observer. The first observation captures reply markers and focus in the browser; diagnostics are retrieved after typing to avoid an additional pre-input round trip. Diagnostics also capture browser-clock timestamps and focus at the first actual input event. Replay never waits for input; starvation can still fail overlap. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records Enter-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak. Reconnect uses three fresh compiled plain-Node children. Each creates a 100,000-delta reasoning prefix with distinct timestamps and two compact records before timing `ClientAssistantStream.replace()`. GC precedes the baseline and follows replacement while the result remains reachable; replacement time excludes both collections. The report consumes the result after collection and checks that the next dense live frame remains accepted. This measures reconstruction, not transport, rendering, or an entire reconnect workflow. diff --git a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md index f563c55e64..c9a174df55 100644 --- a/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md @@ -16,7 +16,7 @@ Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式 浏览器输入包含 240 个已关闭轮次、40 个工具结果和 20 个代码块,以及混合语言正文和推理。历史 Assistant 记录携带匹配的紧凑 stream,通过生产 accumulator 按 12 字符推理/文本 delta 和 8 字符工具参数 delta 构建;空 stream 会遗漏存储与传输负载成本。从观察到的初始 25 轮窗口开始,九次更早分页操作读完该输入;就绪探针跟踪已挂载轮次增长,不复制分页算法。每个样本使用全新 scaffold 和浏览器。环境准备、数据播种、浏览器启动、初始 shell 加载及侧栏展开不计入打开时间。打开测量在对话可用且输入框可编辑时结束;分页与导航测量在目标 DOM 状态出现时结束。两次动画帧包含一次渲染机会,不代表硬件显示或保证每个屏幕外节点都已绘制。 -续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。诊断在首段可见等待后立即记录回复标记和焦点,并记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。 +续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在从已聚焦输入框按 Enter 提交前安装;键入保留焦点,不执行鼠标重新聚焦,也不单独等待输入前动画帧。首段/最终标记查找在动画帧上采样最新 Assistant step 内的可见文本,避免选择器重试退避。隐藏文本和较早步骤中的标记均不能满足观察条件。首次观察在浏览器内记录回复标记与焦点;诊断在键入后取回,避免增加输入前的通信往返。诊断还记录首个实际输入事件的浏览器时钟时间戳与焦点。重放从不等待输入;响应阻塞仍可能导致重叠失败。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录 Enter 提交到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。 重连使用三个全新编译后的纯 Node 子进程。各进程在计时 `ClientAssistantStream.replace()` 前创建包含不同时间戳、两条紧凑记录和 100,000 个 delta 的推理前缀。在基线前执行 GC,并在结果仍可达时于替换后再次 GC;替换时间不含两次回收。报告在回收后消费结果,并检查下一个稠密序号的实时 frame 仍被接受。这测量重建,不测量传输、渲染或完整重连工作流。 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml index c5a83678cc..0409fff118 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.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/testing/2026-09-08-ci-completion-observations.md -2026-09-08-ci-completion-observations.md: 8af4685a5e2c51c1edf4a41b47088916223e7a6c -2026-09-08-ci-completion-observations.zh.md: e3147bb7ac39877b46820862e9af4645803a37a2 +2026-09-08-ci-completion-observations.md: e3de87a70419778407b5eb230cec64ce088dd0c0 +2026-09-08-ci-completion-observations.zh.md: 4e95d6fb9dc9b1ecac2e3d0dfa262b819de16261 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md index 8af4685a5e..e3de87a704 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md @@ -30,7 +30,7 @@ The [LSP backpressure test](../../../../packages/lsp/lsp-stdio/tests/instance.sp ### Built-client import classification -The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for its exact `dockkit.module.css` path. Other errors and unexpectedly successful exempt imports fail. Scoped resolve/load hooks exercise expected CSS failure, arbitrary failure, another stylesheet, another error code, and stale exemption without modifying shared build artifacts. +The [Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts) admits the Dockkit bundle only when Node reports `ERR_UNKNOWN_FILE_EXTENSION` for a `.css` file, and fails every other error and every unexpectedly successful exempt import; the [stylesheet exemption decision](../bug-fix/2026-09-10-built-bundle-css-exemption.md) owns which stylesheets that covers. Scoped resolve/load hooks exercise expected CSS failure, another stylesheet, another extension, arbitrary failure, another error code, and stale exemption without modifying shared build artifacts. ## Alternatives considered diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md index e3147bb7ac..4e95d6fb9d 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md @@ -30,7 +30,7 @@ Status: implemented ### 已构建 Client 的导入分类 -[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对准确的 `dockkit.module.css` 路径报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle。其他错误以及意外成功的豁免导入都会失败。限定范围的 resolve/load hook 覆盖预期 CSS 失败、任意失败、其他 stylesheet、其他错误码和过期豁免,不修改共享构建产物。 +[Node import sweep](../../../../packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts)只有在 Node 针对某个 `.css` 文件报告 `ERR_UNKNOWN_FILE_EXTENSION` 时才接受 Dockkit bundle,其他每个错误以及每个意外成功的豁免导入都会失败;该豁免覆盖哪些样式表由[样式表豁免决策](../bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)拥有。限定范围的 resolve/load hook 覆盖预期 CSS 失败、其他样式表、其他扩展名、任意失败、其他错误码和陈旧豁免,不修改共享构建产物。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml index dbbc4ea266..f96c72c081 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.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/testing/2026-09-08-ci-readiness-and-completion.md -2026-09-08-ci-readiness-and-completion.md: 2249df5467189975aca2d73ec56c6cf82ec7b62f -2026-09-08-ci-readiness-and-completion.zh.md: ccf8e7c16903b66e39bc48e99460e5c5179ab51b +2026-09-08-ci-readiness-and-completion.md: c72048ef90b987e7d27992b1754736b2ce895093 +2026-09-08-ci-readiness-and-completion.zh.md: 1188d66249b3092416435da2e8496a657ad00677 diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md index 2249df5467..c72048ef90 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.md @@ -14,6 +14,8 @@ The [ACP coverage run](https://github.com/deepseek-harness/deepseek-harness/acti A [worker-runtime coverage failure](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932) exhausts the slow-binding fixture's one-second compute allowance. Concurrent native Windows reproductions exceed that allowance before calling the binding. Worker initialization contributes measured active time; the delayed binding contributes idle time. +The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193) reports an SDK subprocess exit beyond a fixture's 200 ms confirmation window and an Inspector Worker startup beyond its ten-second default. Neither the protocol-error routing case nor the Cordis tree projection case measures those latency guarantees. + ## Decision The [webhook browser test](../../../../apps/web/tests/github-ready-review.e2e.ts) observes the model request caused by delivery before checking Session registration. The [feedback test](../../../../apps/web/tests/feedback-command.e2e.ts) waits for the empty composer and enabled attachment control before comparing ARIA output. Matching consecutive snapshots cannot prove that the command RPC has settled: its event stream can publish the acknowledgement first. @@ -28,6 +30,8 @@ The [subagent teardown decision](2026-09-07-subagent-teardown-test-budgets.md) o The [worker-runtime binding test](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts) allows five seconds of compute for source-worker initialization and delays the binding for 6.5 seconds. Charging that idle delay would still exceed the entire compute allowance. The case retains its 15-second test limit and 30-second wall ceiling, registers Context and reply-timer cleanup, and leaves the hot-loop, decoy-dispatch, wall-ceiling, and abort controls at their existing limits. Production budgets remain unchanged. +The [SDK subagent protocol-error test](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts) uses the provider's normal shutdown and exit grace periods and registers disposal before its assertions. The [Inspector tree tests](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts) pass the active test budget to Worker startup and register cleanup while startup is still pending. A cancelled test cannot receive a late-ready handle; cleanup awaits initialization and closes a successfully started Worker. Failed initialization already terminates the Worker before rejecting. A controlled late-start test verifies cancellation and closure through the real Worker's HTTP endpoint. Production defaults remain unchanged. + ## Alternatives considered **Larger independent waits.** Rejected where a completion promise already exists. A separate polling deadline continues to compete with the execution lane's budget. diff --git a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md index ccf8e7c169..1188d66249 100644 --- a/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md +++ b/.agents/notes/implemented/testing/2026-09-08-ci-readiness-and-completion.zh.md @@ -14,6 +14,8 @@ Status: implemented 一次 [worker runtime coverage 失败](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34248221544/job/102135631932)耗尽了慢 binding 夹具的一秒计算额度。原生 Windows 并发复现在调用 binding 前已超过该额度。Worker 初始化会累计所测的活跃时间;延迟的 binding 累计空闲时间。 +[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34324325375/job/102377982193)报告了 SDK 子进程退出超过测试设置的 200 毫秒确认期限,以及 Inspector Worker 启动超过十秒默认期限。协议错误转发用例和 Cordis 树投影用例都不衡量这些延迟保证。 + ## 决策 [Webhook 浏览器测试](../../../../apps/web/tests/github-ready-review.e2e.ts)观察投递触发的模型请求后再检查 Session 注册。[反馈测试](../../../../apps/web/tests/feedback-command.e2e.ts)在比较 ARIA 输出前等待输入框清空且附件按钮启用。连续两次快照相同不能证明命令 RPC 已完成:事件流可能先发布确认消息。 @@ -28,6 +30,8 @@ Status: implemented [Worker runtime binding 测试](../../../../packages/code-runtime/code-runtime-worker-thread/tests/runtime.spec.ts)为源码 worker 初始化保留五秒计算额度,并将 binding 延迟设为 6.5 秒。若将该空闲延迟计费,仍会超过整个计算额度。用例保留 15 秒测试期限与 30 秒墙钟上限,登记 Context 和回复定时器的清理,并保持热循环、诱饵 dispatch、墙钟上限及取消控制用例的原有限制。生产预算不变。 +[SDK 子 Agent 协议错误测试](../../../../packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts)使用提供方正常的关闭和退出等待时间,并在断言前登记清理。[Inspector 树测试](../../../../packages/experimental/inspector/tests/cordis-tree.host.spec.ts)将当前测试预算传给 Worker 启动,并在启动尚未完成时登记清理。取消后的测试不会收到随后才就绪的实例;清理等待初始化完成,并关闭成功启动的 Worker。初始化失败时,启动操作会在拒绝前终止 Worker。受控的延迟启动测试通过真实 Worker 的 HTTP 端点验证取消和关闭。生产默认值不变。 + ## 考虑过的替代方案 **增大独立等待时限。** 已有完成 Promise 时不采用。独立轮询期限仍会与执行通道的预算竞争。 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml index 81de55da66..699ced6cd7 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.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/testing/2026-09-09-user-patch-hmr-test-delivery.md -2026-09-09-user-patch-hmr-test-delivery.md: 427cf938d38eaf5c351fc0334bfd7df766d5663c -2026-09-09-user-patch-hmr-test-delivery.zh.md: c2a3a14c9f322e748dbfd131a98138b13c5d47a6 +2026-09-09-user-patch-hmr-test-delivery.md: fadd191650411f07c3b8b1f35c94e73869fbc8e2 +2026-09-09-user-patch-hmr-test-delivery.zh.md: b108cf25766ff108d87c429e9f208c2d4cf7afd2 diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md index 427cf938d3..fadd191650 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md @@ -1,4 +1,4 @@ -# Agent Note: User-patch transactions control filesystem event delivery +# Agent Note: User-patch tests control filesystem event delivery Status: implemented @@ -6,22 +6,22 @@ English | [中文](2026-09-09-user-patch-hmr-test-delivery.zh.md) ## Problem -The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the transaction assertions from exercising the parser, activation, and rollback behavior they own. +The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the refresh assertions from exercising the parser, activation, and recovery behavior they own. ## Decision -The [user-patch transaction test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. HMR registration, refresh serialization, Include recomposition, plugin activation, failure broadcasting, rollback, and recovery remain real. The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block. +The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block. -The separate [HMR config tests](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The transaction test does not establish operating-system delivery guarantees. +The separate [watcher tests](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees. ## Alternatives considered -**Native notifications for every transaction assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken. +**Native notifications for every refresh assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken. **Polling and fixed settling delays.** Rejected because neither acknowledges delivery of the next edit. Chokidar readiness does not expose completion of Node's asynchronous initial polling baseline; a local polling reproduction still misses changes. Increasing the test deadline cannot recover an event that was never emitted. -**Mock HMR registration or Include.** Rejected because the test must retain transactional recomposition and last-good-state assertions after activation and parse failures. +**Mock HMR registration or Include.** Rejected because the test must retain real recomposition, report activation failures, and preserve the running configuration after parse failures. ## Consequences -The transaction sequence retains every semantic assertion and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis. +The refresh sequence retains its semantic assertions and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis. diff --git a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md index c2a3a14c9f..b108cf2576 100644 --- a/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md +++ b/.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 用户 patch 事务控制文件系统事件投递 +# Agent Note: 用户 patch 测试控制文件系统事件投递 Status: implemented @@ -6,22 +6,22 @@ Status: implemented ## 问题 -[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止事务断言执行其负责验证的解析、激活与回滚行为。 +[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止刷新断言执行其负责验证的解析、激活与回滚行为。 ## 决策 -[用户 patch 事务测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。HMR 注册、刷新串行化、Include 重组、插件激活、失败广播、回滚与恢复仍使用真实实现。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 +[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。 -独立的 [HMR 配置测试](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。事务测试不验证操作系统的投递保证。 +独立的 [监视器测试](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。 ## 考虑过的替代方案 -**每个事务断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。 +**每个刷新断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。 **轮询与固定等待。** 不采用,因为两者都不能确认下一次修改已经投递。Chokidar 就绪状态不暴露 Node 异步初始轮询基线的完成时刻;本地轮询复现仍会遗漏修改。延长测试期限无法恢复从未发出的事件。 -**Mock HMR 注册或 Include。** 不采用,因为测试必须保留事务重组,以及激活和解析失败后的最后有效状态断言。 +**Mock HMR 注册或 Include。** 不采用,因为测试必须保留真实重组、报告激活失败,并在解析失败后保留运行中的配置。 ## 影响 -事务序列保留所有语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。 +刷新序列保留其语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。 diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml new file mode 100644 index 0000000000..241a9e9d5f --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md +2026-09-10-hosted-image-test-assumptions.md: 5cf3b14503162d67d0fbb27743228cb1a8d7ffe2 +2026-09-10-hosted-image-test-assumptions.zh.md: db82566c11b50b9203d30f7be6aec1378512f94b diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md new file mode 100644 index 0000000000..5cf3b14503 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.md @@ -0,0 +1,41 @@ +# Agent Note: Hosted-image assumptions in the coverage suite + +Status: implemented + +English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md) + +## Problem + +The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright. + +## Decision + +Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image. + +Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path. + +`disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory. + +`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. At 150ms the hosted image escalated while the scope could not take the signal — `systemctl` failed the kill (`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`) and the teardown reported a failure the configuration never asked for. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants. + +Both illegal-UTF-8 residual cases in `packages/experimental/code-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start. + +The stray-output sealing test in `packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: [run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies. + +The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host. + +The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)` through PowerShell instead of gating on `process.platform`. An image that answers `CLASS_E_CLASSNOTAVAILABLE` (0x80040111) runs the clean-rejection case and skips the real-dialog case, so `win32-dialog.ts` keeps its file coverage without a host that can open a dialog. Every exception from that activation reads as refusal, so a host failing the probe for another reason only loses the real-dialog case; a probe that cannot run at all keeps the win32 assumption. + +## Alternatives considered + +**Excluding the coverage lanes from the hosted leg.** Rejected: the leg exists to run the same suite on another pool, and the failures named real host dependencies rather than a suite the pool cannot support. + +**Raising only the lane's per-test budget.** Rejected: a wider budget does not change the cases whose cost or behaviour is deterministic — a trapped ACP child still waits out both graces, and a coalesced reader still inflates the measured peak. + +**Keeping the `linux-scope.ts` probe mocks.** Rejected as inert: the platform pin selects the fallback path before either probe is called, so the mock changed no execution path. + +**Cutting the illegal-UTF-8 payloads to keep the cases fast.** Rejected: below the 2048 bound the assertion can no longer fail for the undercount it names, which leaves the regression unguarded. + +## Consequences + +The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`). diff --git a/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md new file mode 100644 index 0000000000..db82566c11 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-10-hosted-image-test-assumptions.zh.md @@ -0,0 +1,41 @@ +# Agent Note: coverage 套件里的托管镜像假设 + +Status: implemented + +[English](2026-09-10-hosted-image-test-assumptions.md) | 中文 + +## 问题 + +[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。 + +## 决策 + +每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。 + +驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径。 + +`disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。 + +`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。在 150ms 时,托管镜像在 scope 还无法接受信号时就升级了信号——`systemctl` 的 kill 失败(`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`),teardown 上报了一个配置从未要求的失败。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。 + +`packages/experimental/code-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。 + +`packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:[run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。 + +Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。 + +Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateInstance(CLSID_FileOpenDialog)`,而不再按 `process.platform` 分流。回答 `CLASS_E_CLASSNOTAVAILABLE`(0x80040111)的镜像会跑干净的拒绝用例并跳过真实对话框用例,因此 `win32-dialog.ts` 在没有可开对话框的宿主上仍保有文件覆盖率。该激活过程抛出的任何异常都按拒绝解读,因此因其它原因探测失败的宿主只会失去真实对话框用例;完全无法运行的探测则保留 win32 假设。 + +## 备选方案 + +**把 coverage 通道排除出托管支路。** 否决:该支路的意义就是把同一套测试跑在另一个池上,而这些失败点出的是真实的宿主依赖,不是一个该池无法支撑的套件。 + +**只抬高通道的每用例预算。** 否决:更宽的预算改变不了那些成本或行为确定的用例——被 trap 的 ACP 子进程仍然会等满两个宽限,被合并的读端仍然会抬高测得的峰值。 + +**保留 `linux-scope.ts` 的探针 mock。** 因无效而否决:平台钉死会在任一探针被调用前就选中 fallback 路径,因此该 mock 没有改变任何执行路径。 + +**削减非法 UTF-8 的载荷以让用例更快。** 否决:低于 2048 的界之后,断言再也无法为它所点名的少计而失败,等于让该回归失去守护。 + +## 后果 + +套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。 diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml index fded384582..d324ff433c 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.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/proposed/feature/2026-07-06-recallable-compaction.md -2026-07-06-recallable-compaction.md: f0cf1b0602ad7dd5ae719fdd1e3b569b0bf5b448 -2026-07-06-recallable-compaction.zh.md: 8e8793f28be848e6231e86a3eaba099e68d9947e +2026-07-06-recallable-compaction.md: 07b89116b72a1c1aac2d79ee5aa41ff6e5d02a4c +2026-07-06-recallable-compaction.zh.md: 7347cb08c94665a6e5bb88655ec08d04921d08eb diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md index f0cf1b0602..07b89116b7 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.md @@ -46,7 +46,7 @@ A new package `@deepseek-ai/dsh-tool-recall` (consumer-only, over the `dsh-sessi - `history_read(checkpoint, offset?)` — renders the shadowed span of any checkpoint in the log, including superseded ones, as `User:`/`Assistant:`/`Tool result:` transcript, paginated by a configured budget with a continuation cursor. - `history_search(query, checkpoint?, limit?)` — case-insensitive literal scan over every shadowed span; returns snippets with checkpoint ids and coverage metadata (`scanned`/`matched`/`truncated`). The zero-match hint notes the scan is literal and points at direct `history_read` of a plausible checkpoint. -Both read `exec.agent.session.snapshotEvents()` (the tool-todo access pattern; non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. +Both use explicit asynchronous, paged history reads under the [synchronous event-read deprecation](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md) (non-agent callers rejected), render only surface-type message events, and return ordinary `tool/result`s — recalled bytes land at the context tail, logged, so reconstructability holds with no special casing. There is no new storage and no sidecar index: the session log stores the content, `compaction/summary.shadowedRange` and `shadowedSeqs` identify what each checkpoint replaced, and the tools read both. The tool schemas and the package's one system-prompt section are static strings; checkpoint ids reach the model only through footers. The transcript renderer moves from `compaction-basic` into `dsh-session`, shared by summarizer and tools. ### Cache and cost @@ -86,7 +86,7 @@ Deferred until observation calls for them: - **One summarize call emitting all outputs** — rejected: the summarize path has no structured-output enforcement; parsing one free-text response apart is the fragile boundary the fail-closed design avoids. - **Model-chosen chunk boundaries** — deferred: parse-and-validate cost against unproven value; chunk policy sits behind config. - **Model-authored pointers** — rejected: pointers must be exact; deterministic assembly is. -- **FTS/vector index sidecar** — rejected in-session: the live log is in memory and bounded, a literal scan under budget suffices; an index earns its keep at cross-session scope. +- **FTS/vector index sidecar** — the rejection based on a resident, bounded live log is superseded by the [event-read policy](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). Reassess the index choice against paged historical reads before implementing recall. - **Semantic search fallback / secondary-model extraction in the recall path** — rejected: an LLM or embedding call there breaks keyless replay determinism; recall stays a pure function of the log. - **Raw events instead of rendered transcript** — rejected: leaks log-only vocabulary and chunk noise; the model reads what a model once saw. - **Doing nothing (resume/fork as recovery)** — rejected: it makes recovery a human act. diff --git a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md index 8e8793f28b..7347cb08c9 100644 --- a/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md +++ b/.agents/notes/proposed/feature/2026-07-06-recallable-compaction.zh.md @@ -46,7 +46,7 @@ Status: proposed - `history_read(checkpoint, offset?)`:把日志中任意检查点(包括已被取代的检查点)遮蔽的区段渲染为 `User:`/`Assistant:`/`Tool result:` transcript(文本记录),并按配置预算分页,提供续传游标。 - `history_search(query, checkpoint?, limit?)`:对每个被遮蔽区段进行不区分大小写的字面量扫描;返回带检查点 id 的片段与覆盖元数据(`scanned`/`matched`/`truncated`)。零匹配提示会说明扫描按字面量执行,并建议对可能的检查点直接使用 `history_read`。 -两个工具都读取 `exec.agent.session.snapshotEvents()`(沿用 tool-todo 访问模式;拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 +两个工具都按[同步事件读取弃用规则](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)使用显式异步分页历史读取(拒绝非 agent(智能体)调用方),只渲染表面类型的消息事件,并返回普通 `tool/result`:回溯字节会进入上下文尾部并记录到日志,因此无需特殊处理即可满足可重建性。系统不增加新存储或伴随索引:会话日志存储内容,`compaction/summary.shadowedRange` 和 `shadowedSeqs` 指明每个检查点替换了什么,这些工具读取两者。工具 schema 与该包唯一的系统提示词章节都是静态字符串;检查点 id 只会通过页脚抵达模型。transcript 渲染器从 `compaction-basic` 移入 `dsh-session`,供摘要器与工具共享。 ### 缓存与成本 @@ -86,7 +86,7 @@ Status: proposed - **一次摘要调用输出全部结果**:不予采纳,因为摘要路径没有结构化输出约束;解析一份自由文本响应并将其拆开,正是保守失败设计要避免的脆弱边界。 - **由模型选择分片边界**:延后实现,因为相对于未经证明的收益,解析与校验成本过高;分片策略由配置控制。 - **由模型编写指针**:不予采纳,因为指针必须精确,应由确定性代码组装。 -- **FTS/向量索引伴随存储**:在会话内不予采纳,因为实时日志已在内存中且大小有界,在预算内进行字面量扫描已经足够;只有跨会话范围才能证明索引的价值。 +- **FTS/向量索引伴随存储**:以实时日志常驻内存且大小有界为依据的否决,被[事件读取策略](../../implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md)取代。实现回溯前,需要针对分页历史读取重新评估索引选择。 - **回溯路径中的语义搜索回退/次级模型提取**:不予采纳,因为其中的 LLM 或嵌入调用会破坏无密钥回放的确定性;回溯必须保持为日志的纯函数。 - **使用原始事件而不是渲染后的 transcript**:不予采纳,因为这会泄漏仅日志可见的词汇与分片噪声;模型应读取模型曾经看到的内容。 - **什么都不做(用恢复/fork 补救)**:不予采纳,因为这会把恢复变成人工操作。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml new file mode 100644 index 0000000000..cd64ee4423 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.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/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md +2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7 +2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md new file mode 100644 index 0000000000..e1c2435814 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md @@ -0,0 +1,95 @@ +# Agent Note: Uninstall Desktop while preserving the Harness home + +Status: proposed + +English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md) + +## Problem + +Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage. + +The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory. + +## Proposal + +Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup. + +Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup. + +User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history. + +### 1. Establish ownership and actual paths + +Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths. + +| Candidate | Windows | macOS | Required treatment | +|---|---|---|---| +| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. | +| Installer/update cache | `%LOCALAPPDATA%/` | `~/Library/Caches/` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. | +| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. | +| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. | +| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. | +| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. | + +Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts. + +### 2. Implement shared preservation and cleanup rules + +Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location. + +Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview. + +Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup. + +Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable. + +### 3. Windows implementation + +Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup. + +Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`. + +Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result. + +Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged. + +### 4. macOS implementation + +Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied. + +Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification. + +Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned. + +### 5. Verification and documentation + +Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests. + +Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed. + +Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests. + +## Alternatives considered + +**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome. + +**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here. + +**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation. + +**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations. + +## Acceptance criteria + +- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains. +- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact. +- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data. +- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash. +- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result. +- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion. + +## Risks + +Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention. + +OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md new file mode 100644 index 0000000000..27fd7c8cbe --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md @@ -0,0 +1,95 @@ +# Agent Note: 卸载 Desktop 并保留 Harness 主目录 + +Status: proposed + +[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文 + +## 问题 + +用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。 + +[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。 + +## 提案 + +提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。 + +Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。 + +用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。 + +### 1. 确定归属和实际路径 + +使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。 + +| 候选项 | Windows | macOS | 处理要求 | +|---|---|---|---| +| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 | +| 安装器及更新缓存 | `%LOCALAPPDATA%/` | `~/Library/Caches/` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 | +| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 | +| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 | +| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 | +| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 | + +建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。 + +### 2. 实现共用的保留与清理规则 + +按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。 + +拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。 + +阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。 + +不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。 + +### 3. Windows 实现 + +保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。 + +正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。 + +按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。 + +由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。 + +### 4. macOS 实现 + +在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。 + +使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。 + +把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。 + +### 5. 验证和文档 + +增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。 + +在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。 + +为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。 + +## 考虑过的替代方案 + +**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。 + +**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。 + +**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。 + +**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。 + +## 验收标准 + +- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。 +- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。 +- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。 +- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。 +- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。 +- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。 + +## 风险 + +删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。 + +操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 663762c55d..c07d097e40 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -31,6 +31,7 @@ There is no universal local baseline beyond the hooks. Every behavior change nee When the outgoing change adds or changes a resource-owning or asynchronous test, fixture, helper, or CI execution path, use [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md) first to decide whether restoration, negative-control, quiescent-teardown, or concurrent-process evidence applies. This skill still selects the commands and avoids repeating evidence that already passed. - **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. +- **Remote mock typing:** unbuilt `any` is an explicit local fallback, not strict evidence. Run `pnpm run typecheck` before handing off Remote/mock changes; rebuild missing, stale, or partial generated declarations before diagnosing remaining errors. Keep the exception in the [test proxy](../../../packages/test-support/remote-mock/README.md#remote-proxy), never in production Remote types, ambient flags, or copied signatures. - **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. - **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. - **Expected-output placement:** a test whose selected recorded Session generation is replay input and expected persisted output belongs under top-level `snapshots/`, with `snapshot.yml` naming its shipped `dsh` profile and composition/header pin. Canonical parent files are `session[.vN].jsonl`, children are `session.[.vN].jsonl`, and the harness selects the highest generation per role. ARIA, geometry, generator, CLI, and unit expectations without that Session round trip stay beside their owning test under `tests/expected/`; do not place them in `snapshots/` or give them a `*.snapshot.ts` owner. Use the owning `test:expected`, `test:web`, or `test` lane. diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index dbb4252fec..be96b7effa 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -249,10 +249,9 @@ jobs: - name: Configure persistent pnpm store shell: pwsh # The store must share the ReFS workspace volume for the clone - # import method below; LOCALAPPDATA (C:) would cross volumes and - # break block clone. See 2026-08-30-windows-refs-store-block-clone-install. + # import method below. Runner workspaces can reside on different drives. run: | - $storeRoot = "F:\.pnpm-store" + $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - name: Install (immutable) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c06a52d15..57ed55c36d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,14 @@ jobs: DSH_COVERAGE_MAX_WORKERS: '6' DSH_COVERAGE_PARTITIONS: '4' DSH_GATE_CONCURRENCY: '3' + # Managed-scope teardown cases run past the default per-test budget when + # this lane's partitions, workers, and sibling gates share one host: the + # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on + # the hosted image (run 34449848541) while the same commit stayed inside + # the budget on the in-house pool; the lane is green with this value + # (run 34457655892). The Windows coverage lane grants the same budget for + # the same reason. + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' # A gate failure aborts the sibling gate instead of waiting out its # multi-minute instrumented run. DSH_GATE_FAIL_FAST: '1' diff --git a/.oxlintrc.json b/.oxlintrc.json index 1f11a9e989..348b057bc3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -203,6 +203,19 @@ "scripts/**/*.spec.{ts,tsx}" ], "rules": { + // Session assertions inspect the log; other deprecated APIs remain errors. + "typescript/no-deprecated": [ + "error", + { + "allow": [ + { + "from": "file", + "name": ["snapshotEvents", "eventAt", "ownEvents"], + "path": "packages/core/session/src/index.ts" + } + ] + } + ], "typescript/no-invalid-void-type": "error", "typescript/no-non-null-assertion": "off", // Assertions commonly follow an expect() that proves presence. "typescript/no-unnecessary-condition": "off", diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 31eebdab8d..34b3f4481d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -114,6 +114,7 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: +- `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) @@ -169,6 +170,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | +| [`@vitest/spy`](https://github.com/vitest-dev/vitest) | MIT | | [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`app-builder-lib`](https://github.com/electron-userland/electron-builder) | MIT | @@ -191,7 +193,6 @@ External packages **directly declared** for development, tests, types, or toolin | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | | [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | -| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT | | [`oxlint`](https://github.com/oxc-project/oxc) | MIT | | [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | | [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index c642fb74c4..4f97c14239 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 92a5a07c3035e5814e28522c4df915f6931ad96e -README.zh.md: 25f6652b89e474b084f7eab54f59def655e6886c +README.md: f8d89bd8b3cfc077a46da37f9c23d5050743e4b1 +README.zh.md: 4a22cc1a399dc5d8c9103952786d908a52be7f3e diff --git a/apps/cli/README.md b/apps/cli/README.md index 92a5a07c30..f8d89bd8b3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero. +The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, and fatal configuration or boot failures exit nonzero. ## Entry modes @@ -45,7 +45,7 @@ Bundles named in `dsh.profile.bundles` resolve from the dsh installation first ( Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. -The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. +The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR. ## Optional overlays @@ -54,3 +54,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f ## Development Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. + +The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 25f6652b89..4a22cc1a39 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。 +`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项,以及致命的配置或启动错误都会以非零状态退出。 ## 入口模式 @@ -45,7 +45,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 -层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。 +层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。 ## 可选覆盖层 @@ -54,3 +54,5 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 ## 开发 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。 + +[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。 diff --git a/apps/cli/package.json b/apps/cli/package.json index cfbbef8fb4..b742aa622d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 1d0c761b5b..c62ca02586 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -410,6 +410,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' }) } await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } }) + await ctx.loader.await() } await watchUserPatches(ctx, { binName: NAME, filename: composed.profile.patchPath, reapply }) await watchUserPatches(ctx, { binName: NAME, filename: homePatchPath(), reapply }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ef606d49af..af8be201e1 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -28,6 +28,7 @@ const SPAWN_TIMEOUT_MS = 60_000 const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) +const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href async function runBuiltBin( args: readonly string[] = [], @@ -400,7 +401,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, SPAWN_TIMEOUT_MS * 3 + 30_000) - it('reports SDK startup failure when stdin reaches EOF first', async () => { + it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) const patch = join(home, 'broken-sdk.cordis.yml') writeFileSync(patch, [ @@ -415,9 +416,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call', }, home) - expect(result.code).toBe(1) + expect(result.code).toBe(0) expect(result.stdout).toBe('') - expect(result.stderr).toContain('plugin tree failed to load') + expect(result.stderr).toContain('warning: 1 entry did not activate') expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin') } finally { rmSync(home, { recursive: true, force: true }) @@ -756,20 +757,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, SPAWN_TIMEOUT_MS + 30_000) - it('reports a patch-overlay boot failure without hanging', async () => { - // The HMR main watcher's initial scan once refreshed the include - // mid-initial-apply, deadlocking the failing apply's rollback against the - // refresh drain: dsh exited 13 with no diagnostic instead of settling - // ([vendor/README.md](../../../vendor/README.md)). + it('keeps serving when an optional patch-overlay plugin fails', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { - const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], { + const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], { DSH_HOME: home, + DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1', DEEPSEEK_API_KEY: 'keyless-invalid-config', DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: `--import=${webReadyExitHook}`, }) - expect(result.code).toBe(1) - expect(result.stdout).toBe('') + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u) expect(result.stderr).toContain('llm-pi-ai') } finally { rmSync(home, { recursive: true, force: true }) diff --git a/apps/cli/tests/fixtures/invalid-provider.cordis.yml b/apps/cli/tests/fixtures/invalid-provider.cordis.yml index 88534a1680..5aea6833ea 100644 --- a/apps/cli/tests/fixtures/invalid-provider.cordis.yml +++ b/apps/cli/tests/fixtures/invalid-provider.cordis.yml @@ -1,4 +1,4 @@ -# Invalid `--patch` overlay used to prove boot failures settle and exit. +# Invalid optional provider override used to prove best-effort startup continues. - id: llm-pi-ai config: diff --git a/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt b/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt index f3252180b9..1642b96a96 100644 --- a/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt +++ b/apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt @@ -1,3 +1,5 @@ -headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure -Error: startup activation snapshot failure +dsh: warning: 1 entry did not activate +activation-error (./activation-error.mjs): Error: startup activation snapshot failure at activation-error-fixture +dsh: reasoning: +Inspecting the task before the tool call. diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml index ce6c7ef949..5acdd928ea 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/startup-activation-error/activation-error.patch.yml @@ -1,10 +1,4 @@ -# Activation-failure patch over the shipped headless profile. -- id: headless-startup - disabled: true - -- id: headless-runner - disabled: true - +# Unrelated activation failure over the shipped headless profile. - insert: - id: activation-error name: ./activation-error.mjs diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 48ea951be0..bb31c90060 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -271,19 +271,27 @@ describe('headless stream-json snapshots', () => { await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('prints the original Loader activation error through the assembled one-shot app', async () => { + it('warns about an unrelated activation error and completes the headless task', async () => { const result = await runLoaderSmoke({ - label: 'headless startup activation error snapshot', + label: 'headless best-effort startup snapshot', tempDirPrefix: 'headless-snapshot-startup-error-', - binScript, - libBinScript: binScript, + binScript: dshBinScript, configPath: startupFailureConfigPath, - binArgs: [startupFailureConfigPath, 'unreachable task'], + binArgs: [ + '--profile', 'headless', + '--patch', headlessOverlayPath, + '--patch', startupFailureConfigPath, + 'Complete the task despite the unrelated startup failure.', + ], tsconfigPath, - expectedExitCode: 1, + env: { + DSH_PERMISSION_MODE: 'danger-full-access', + DSH_TELEMETRY_DISABLED: '1', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, }) - expect(result.stdout).toBe('') - await expect(result.stderr.replace(startupFailurePluginUrl, './activation-error.mjs')) + expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') + await expect(result.stderr.replaceAll(startupFailurePluginUrl, './activation-error.mjs')) .toMatchFileSnapshot(startupFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts index e9e08bb302..dd0730e6f5 100644 --- a/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/mcp-pagination.expected.e2e.ts @@ -6,24 +6,32 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url) const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot)) +const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url)) const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url)) -it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => { +it('warns about a repeated MCP discovery cursor and completes the headless task', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'MCP discovery pagination cycle', tempDirPrefix: 'dsh-mcp-pagination-', binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)), libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)), configPath, - binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'], + binArgs: [ + '--profile', 'headless', + '--patch', headlessOverlayPath, + '--patch', configPath, + 'Complete the task without the failed MCP server.', + ], tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)), - expectedExitCode: 1, env: { DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)), + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', }, }) - expect(stdout).toBe('') + expect(stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') + expect(stderr).toContain('dsh: warning: 1 entry did not activate') + expect(stderr).toContain('mcp-pagination-cycle (@deepseek-ai/dsh-mcp-client)') expect(stderr).toContain('initial connection or tool synchronization failed') const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):')) await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath) diff --git a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts index 6f45fa4caa..1574c2cab1 100644 --- a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts @@ -32,6 +32,10 @@ const binScript = fileURLToPath(new URL('../../../../../../packages/test-support const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)) // The resumed-agent fixture in the shared config resumes exactly this id. const sessionId = SessionId('workspace-context-resume') +const fixtureEnv = { + DSH_SNAPSHOT_FILE: replayFixture, + DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID: 'resumed-agent', +} /** Persist one session with the given header version and events, returning its log path. */ async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise { @@ -84,7 +88,7 @@ describe('session format guard through the assembled app', () => { configPath, binArgs: [configPath, 'Continue the migrated session.'], tsconfigPath, - env: { DSH_SNAPSHOT_FILE: replayFixture }, + env: fixtureEnv, prepare: async (runCwd) => { sourcePath = await seedSession(join(runCwd, '.sessions'), runCwd, 0, closedTurn()) source = await readFile(sourcePath) @@ -124,7 +128,7 @@ describe('session format guard through the assembled app', () => { configPath, binArgs: [configPath, 'Try to resume.'], tsconfigPath, - env: { DSH_SNAPSHOT_FILE: replayFixture }, + env: fixtureEnv, expectedExitCode: 1, prepare: async (runCwd) => { sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn()) @@ -151,7 +155,7 @@ describe('session format guard through the assembled app', () => { configPath, binArgs: [configPath, 'Try to resume.'], tsconfigPath, - env: { DSH_SNAPSHOT_FILE: replayFixture }, + env: fixtureEnv, expectedExitCode: 1, prepare: async (runCwd) => { sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none') @@ -197,7 +201,7 @@ describe('session format guard through the assembled app', () => { configPath, binArgs: [configPath, 'Try to resume.'], tsconfigPath, - env: { DSH_SNAPSHOT_FILE: replayFixture }, + env: fixtureEnv, expectedExitCode: 1, prepare: async (runCwd) => { sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [ diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index c879e5b8b9..a8630816f7 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -355,7 +355,8 @@ describe('Python SDK dsh profile keyless smoke', () => { expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin tree failed to load') - expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') + expect(stderr).toContain('required startup failure') + expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError') expect(stderr).toContain('sometimes') } finally { await rm(root, { recursive: true, force: true }) diff --git a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts new file mode 100644 index 0000000000..f8485a7de7 --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts @@ -0,0 +1,350 @@ +/** Built Web-profile acceptance for best-effort initial plugin activation. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Readable } from 'node:stream' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') +const frontendIndex = join(repoRoot, 'apps/web/dist/index.html') +const builtArtifactsExist = existsSync(dshBin) && existsSync(frontendIndex) + +interface Fixture { + root: string + home: string + patch: string + events: string + stop: string +} + +function createFixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-best-effort-')) + const home = join(root, 'home') + const events = join(root, 'events.log') + const stop = join(root, 'stop') + mkdirSync(home) + writeFileSync(events, '') + writeFileSync(join(root, 'good.mjs'), [ + "import { appendFileSync, existsSync } from 'node:fs'", + 'export function apply(ctx, config) {', + " appendFileSync(config.events, 'good apply\\n')", + ' let stopping = false', + ' const watcher = setInterval(() => {', + ' if (stopping || !existsSync(config.stop)) return', + ' stopping = true', + " process.emit('SIGTERM')", + ' }, 20)', + ' ctx.effect(() => () => {', + ' clearInterval(watcher)', + " appendFileSync(config.events, 'good dispose\\n')", + ' })', + '}', + '', + ].join('\n')) + writeFileSync(join(root, 'sync-failure.mjs'), 'export function apply() { throw new Error("web sync apply failure") }\n') + writeFileSync(join(root, 'async-failure.mjs'), [ + 'export async function apply() {', + ' await Promise.resolve()', + ' throw new Error("web async apply failure")', + '}', + '', + ].join('\n')) + writeFileSync(join(root, 'pending.mjs'), [ + "export const inject = ['webProbeMissingService']", + 'export function apply() {}', + '', + ].join('\n')) + const patch = join(root, 'failures.patch.yml') + writeFileSync(patch, [ + '- id: tool-todo', + ' disabled: false', + ' config: {}', + '- insert:', + ' - id: web-probe-good', + ` name: ${pathToFileURL(join(root, 'good.mjs')).href}`, + ' config:', + ` events: ${JSON.stringify(events)}`, + ` stop: ${JSON.stringify(stop)}`, + ' - id: web-probe-import-failure', + ` name: ${pathToFileURL(join(root, 'missing.mjs')).href}`, + ' - id: web-probe-sync-failure', + ` name: ${pathToFileURL(join(root, 'sync-failure.mjs')).href}`, + ' - id: web-probe-async-failure', + ` name: ${pathToFileURL(join(root, 'async-failure.mjs')).href}`, + ' - id: web-probe-pending', + ` name: ${pathToFileURL(join(root, 'pending.mjs')).href}`, + ' - id: web-probe-disabled-failure', + ` name: ${pathToFileURL(join(root, 'good.mjs')).href}`, + ' disabled: !!js "JSON.parse(\'invalid\')"', + '', + ].join('\n')) + return { root, home, patch, events, stop } +} + +async function waitForStartup( + stdout: Readable | null, + stderr: Readable | null, + completion: PromiseLike<{ exitCode?: number }>, +): Promise<{ url: string; stderr: string }> { + if (stdout === null || stderr === null) throw new Error('Web child pipes are unavailable') + stdout.setEncoding('utf8') + stderr.setEncoding('utf8') + let stdoutText = '' + let stderrText = '' + let url: string | undefined + const ready = Promise.withResolvers<{ url: string; stderr: string }>() + let settled = false + const finish = (): void => { + if (settled || url === undefined) return + if (!stderrText.includes('dsh: warning: 6 entries did not activate')) return + if (!stderrText.includes('web async apply failure')) return + if (!stderrText.includes('webProbeMissingService')) return + settled = true + clearTimeout(timer) + ready.resolve({ url, stderr: stderrText }) + } + stdout.on('data', (chunk: string) => { + stdoutText += chunk + url ??= /dsh web: (http:\/\/[^\s]+)/u.exec(stdoutText)?.[1] + finish() + }) + stderr.on('data', (chunk: string) => { + stderrText += chunk + finish() + }) + const timer = setTimeout(() => { + if (settled) return + settled = true + ready.reject(new Error(`Web profile did not report ready and failed entries\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`)) + }, 60_000) + void completion.then((result) => { + if (settled) return + settled = true + clearTimeout(timer) + ready.reject(new Error(`Web profile exited before readiness (code ${String(result.exitCode)})\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`)) + }) + return ready.promise +} + +describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () => { + it('serves the full Web app while unrelated entries fail to start', async () => { + const fixture = createFixture() + const child = execa(process.execPath, [ + dshBin, + '--profile', 'web', + '--patch', fixture.patch, + '--no-open', + '--port', '0', + ], { + cwd: fixture.root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-best-effort-no-call', + DSH_AGENTS_HOME: join(fixture.root, '.agents'), + DSH_HOME: fixture.home, + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + }, + input: '', + reject: false, + timeout: 90_000, + killSignal: 'SIGKILL', + }) + + let result: Awaited + let events = '' + try { + const startup = await waitForStartup(child.stdout, child.stderr, child) + const auth = await fetch(startup.url, { redirect: 'manual' }) + const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0] + if (cookie === undefined) throw new Error('Web authentication response did not set a cookie') + const page = await fetch(new URL('/', startup.url), { headers: { cookie } }) + const html = await page.text() + expect(html).toContain('
') + expect(html).toContain('__DSH_BOOT__') + expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n') + expect(startup.stderr).toContain('web-probe-import-failure') + expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo') + expect(startup.stderr).toContain('web sync apply failure') + expect(startup.stderr).toContain('web async apply failure') + expect(startup.stderr).toContain('pending (waiting for service: webProbeMissingService)') + expect(startup.stderr).toContain('web-probe-disabled-failure') + expect(startup.stderr).toContain('disabled expression failed: SyntaxError') + } finally { + writeFileSync(fixture.stop, 'stop') + result = await child + events = readFileSync(fixture.events, 'utf8') + rmSync(fixture.root, { recursive: true, force: true }) + } + + expect(result.signal).toBeUndefined() + expect({ + exitCode: result.exitCode, + timedOut: result.timedOut, + events, + }).toMatchInlineSnapshot(` + { + "events": "good apply + good dispose + ", + "exitCode": 0, + "timedOut": false, + } + `) + }) + + it.each([ + ['modules', 'missing dependency'], + ['connection', 'missing dependency'], + ['modules', 'disabled expression'], + ['connection', 'disabled expression'], + ])('fails the full Web profile on required %s %s failure', async (id, failure) => { + const fixture = createFixture() + const patch = failure === 'disabled expression' + ? 'disabled: !!js "JSON.parse(\'invalid\')"' + : 'inject: [webProbeMissingRequiredService]' + const diagnostic = failure === 'disabled expression' + ? 'disabled expression failed: SyntaxError' + : 'pending (waiting for service: webProbeMissingRequiredService)' + writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n ${patch}\n`) + try { + const result = await execa(process.execPath, [ + dshBin, + '--profile', 'web', + '--patch', fixture.patch, + '--no-open', + '--port', '0', + ], { + cwd: fixture.root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-required-no-call', + DSH_AGENTS_HOME: join(fixture.root, '.agents'), + DSH_HOME: fixture.home, + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + }, + input: '', + reject: false, + timeout: 90_000, + killSignal: 'SIGKILL', + }) + expect(result.timedOut).toBe(false) + expect(result.signal).toBeUndefined() + expect(result.exitCode).toBe(1) + expect(result.stdout).not.toContain('dsh web: http://') + expect(result.stderr).toContain('required startup failure') + expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`) + expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n') + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + }) + + it('fails the full Web profile when its required HTTP server cannot bind', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-')) + const home = join(root, 'home') + mkdirSync(home) + const blocker = createServer() + await new Promise((resolve, reject) => { + const fail = (error: Error): void => { reject(error) } + blocker.once('error', fail) + blocker.listen(0, '127.0.0.1', () => { + blocker.off('error', fail) + resolve() + }) + }) + const address = blocker.address() + if (address === null || typeof address === 'string') { + throw new Error('port blocker did not bind a TCP address') + } + + try { + const result = await execa(process.execPath, [ + dshBin, + '--profile', 'web', + '--no-open', + '--port', String(address.port), + ], { + cwd: root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-required-bind-no-call', + DSH_AGENTS_HOME: join(root, '.agents'), + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + }, + input: '', + reject: false, + timeout: 90_000, + killSignal: 'SIGKILL', + }) + expect(result.timedOut).toBe(false) + expect(result.signal).toBeUndefined() + expect(result.exitCode).toBe(1) + expect(result.stdout).not.toContain('dsh web: http://') + expect(result.stderr).toContain('required startup failure') + expect(result.stderr).toContain('EADDRINUSE') + } finally { + await new Promise((resolve, reject) => { + blocker.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + rmSync(root, { recursive: true, force: true }) + } + }) + + it('fails and cleans up when detached work rejects after application startup', async () => { + const fixture = createFixture() + const plugin = join(fixture.root, 'detached.mjs') + writeFileSync(plugin, [ + 'export function apply(ctx) {', + ' ctx.effect(() => ctx.get("appReady").onReady(() => {', + ' void Promise.reject(new Error("detached Web failure"))', + ' }))', + '}', + '', + ].join('\n')) + writeFileSync(fixture.patch, readFileSync(fixture.patch, 'utf8') + [ + '- insert:', + ' - id: detached-probe', + ` name: ${pathToFileURL(plugin).href}`, + '', + ].join('\n')) + try { + const result = await execa(process.execPath, [ + dshBin, + '--profile', 'web', + '--patch', fixture.patch, + '--no-open', + '--port', '0', + ], { + cwd: fixture.root, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-detached-no-call', + DSH_AGENTS_HOME: join(fixture.root, '.agents'), + DSH_HOME: fixture.home, + DSH_TELEMETRY_DISABLED: '1', + NODE_NO_WARNINGS: '1', + }, + input: '', + reject: false, + timeout: 90_000, + killSignal: 'SIGKILL', + }) + expect(result.timedOut).toBe(false) + expect(result.signal).toBeUndefined() + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('fatal load failure: Error: detached Web failure') + expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n') + } finally { + rmSync(fixture.root, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts new file mode 100644 index 0000000000..d2d14a5ada --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -0,0 +1,404 @@ +/** Failure policy through the built Web process and native configuration watcher. */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { createServer } from 'node:http' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it } from 'vitest' +import { FiberState } from '@deepseek-ai/cordis' + +const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) +const bin = join(repoRoot, 'apps/cli/lib/bin.js') +const built = existsSync(bin) && existsSync(join(repoRoot, 'apps/web/dist/index.html')) +const failures = [ + ['import', 'missing.mjs'], + ['module evaluation', 'matrix module evaluation'], + ['schema', 'matrix schema failure'], + ['config expression', 'matrix config expression'], + ['disabled expression', 'matrix disabled expression'], + ['sync apply', 'matrix sync apply'], + ['async apply', 'matrix async apply'], + ['dependency', 'matrixMissingService'], +] as const +type Failure = typeof failures[number][0] + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-failure-matrix-')) + const home = join(root, 'home') + mkdirSync(home) + const events = join(root, 'events') + const diagnostics = join(root, 'diagnostics') + const serverUrl = join(root, 'server-url') + const states = join(root, 'states') + const stop = join(root, 'stop') + const patch = join(home, 'cordis.patch.yml') + const watcher = join(root, 'watcher.patch.yml') + // Native delivery remains real; completed writes bypass Chokidar's 50 ms change suppression. + writeFileSync(watcher, JSON.stringify([{ id: 'hmr', disabled: false, config: { + root: [], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + } }]) + '\n') + writeFileSync(events, '') + writeFileSync(diagnostics, '') + writeFileSync(states, '{}') + const observerPath = join(root, 'observer.mjs') + // HMR reports through Cordis logger exporters; WARN is above their default INFO threshold. + writeFileSync(observerPath, [ + "import { appendFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'", + "import { inspect } from 'node:util'", + 'export function apply(ctx, config) {', + ' const report = message => appendFileSync(config.path, message.args.map(arg => inspect(arg)).join(" ") + "\\n")', + ' ctx.logger.buffer.forEach(report)', + ' ctx.logger.exporter({ levels: { default: 3 }, export: report })', + ' const timer = setInterval(() => {', + ' const loader = ctx.get("loader")', + ' if (!loader) return', + ' writeFileSync(config.states + ".tmp", JSON.stringify(Object.fromEntries([...loader.entries()].map(entry => [entry.options.id, entry.fiber?.state]))))', + ' renameSync(config.states + ".tmp", config.states)', + ' }, 20)', + ' ctx.effect(() => () => { clearInterval(timer) })', + ' ctx.inject(["webServer", "connection"], scope => {', + ' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))', + ' scope.effect(() => () => { rmSync(config.url, { force: true }) })', + ' })', + '}', + '', + ].join('\n')) + const observer = { id: 'matrix-log-observer', name: pathToFileURL(observerPath).href, config: { path: diagnostics, url: serverUrl, states } } + const plugin = join(root, 'probe.mjs') + writeFileSync(plugin, [ + "import { appendFileSync, existsSync } from 'node:fs'", + 'export const Config = { "~standard": { version: 1, vendor: "matrix", validate(value) {', + ' return value.mode === "schema" ? { issues: [{ message: "matrix schema failure" }] } : { value }', + '} } }', + 'export const apply = (ctx, config) => {', + ' if (config.mode === "sync apply") throw new Error("matrix sync apply")', + ' return activate(ctx, config)', + '}', + 'async function activate(ctx, config) {', + ' if (config.mode === "async apply") { await Promise.resolve(); throw new Error("matrix async apply") }', + ' if (config.provider) ctx.provide(config.provider, true)', + ' if (config.mode === "detached") setImmediate(() => { void Promise.reject(new Error("matrix detached failure")) })', + ' appendFileSync(config.events, `${config.label} apply ${config.generation}\\n`)', + ' const timer = setInterval(() => { if (existsSync(config.stop)) process.emit("SIGTERM") }, 20)', + ' ctx.effect(() => () => { clearInterval(timer); appendFileSync(config.events, `${config.label} dispose ${config.generation}\\n`) })', + '}', + '', + ].join('\n')) + writeFileSync(join(root, 'evaluation.mjs'), 'throw new Error("matrix module evaluation")\n') + const url = pathToFileURL(plugin).href + const config = (label: string, generation: number, mode = '') => ({ label, generation, mode, events, stop }) + const witness = (generation: number) => ({ id: 'matrix-witness', name: url, config: config('witness', generation) }) + const target = (id: string, failure?: Failure, generation = 1) => ({ + id, + name: failure === 'import' ? pathToFileURL(join(root, 'missing.mjs')).href + : failure === 'module evaluation' ? pathToFileURL(join(root, 'evaluation.mjs')).href : url, + ...(failure === 'dependency' ? { inject: ['matrixMissingService'] } : {}), + config: config('target', generation, failure), + }) + const render = (id: string, failure?: Failure, generation = 1) => { + let text = `- insert: ${JSON.stringify([observer, witness(generation), target(id, failure, generation)])}` + if (failure === 'config expression') text += `\n- id: ${id}\n config: !!js "(() => { throw new Error('matrix config expression') })()"\n` + if (failure === 'disabled expression') text += `\n- id: ${id}\n disabled: !!js "(() => { throw new Error('matrix disabled expression') })()"\n` + return text + '\n' + } + writeFileSync(patch, JSON.stringify([{ insert: [observer, witness(0)] }]) + '\n') + return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target } +} + +function start(f: ReturnType, extra: string[] = []) { + const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], { + cwd: f.root, + env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' }, + input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL', + }) + let stdout = '' + let stderr = '' + child.stdout?.setEncoding('utf8').on('data', (text: string) => { stdout += text }) + child.stderr?.setEncoding('utf8').on('data', (text: string) => { stderr += text }) + let exited = false + void child.then(() => { exited = true }) + async function wait(predicate: () => boolean) { + try { + await expect.poll(() => { + if (exited) throw new Error('Web process exited') + return predicate() + }, { timeout: 45_000 }).toBe(true) + } catch (cause) { throw new Error(`Web condition failed\n${stdout}\n${stderr}\n${readFileSync(f.diagnostics, 'utf8')}\n${readFileSync(f.events, 'utf8')}`, { cause }) } + } + async function serves(currentServer = false) { + await wait(() => /dsh web: http:\/\//u.test(stdout)) + const url = currentServer ? readFileSync(f.serverUrl, 'utf8') : /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1] + if (!url) throw new Error('Missing Web URL') + const auth = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10_000) }) + const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0] + if (!cookie) throw new Error('Missing Web authentication cookie') + const response = await fetch(new URL('/', url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) }) + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('__DSH_BOOT__') + const bundlePath = / + + diff --git a/apps/desktop/renderer/startup.js b/apps/desktop/renderer/startup.js new file mode 100644 index 0000000000..5146bf6f5e --- /dev/null +++ b/apps/desktop/renderer/startup.js @@ -0,0 +1,45 @@ +const api = window.dshDesktop + +async function main() { + const { id, messages } = await api.locale() + document.documentElement.lang = id + document.querySelector('#page-title').textContent = messages.startupLoading + document.querySelector('#restart').textContent = messages.restartApplication + document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins + document.querySelector('#reset-configuration').textContent = messages.resetConfiguration + document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice + document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice + function render(state) { + const failed = state.phase === 'error' + document.querySelector('main').setAttribute('aria-busy', String(!failed)) + document.querySelector('#spinner').hidden = failed + document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading + document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription + document.querySelector('#error').hidden = !failed + document.querySelector('#error').textContent = failed ? state.message : '' + document.querySelector('#actions').hidden = !failed + for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed + for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) { + document.querySelector(selector).hidden = !failed || !state.profileRecovery + } + document.querySelector('#reinstall-advice').hidden = !failed + } + let changed = false + const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) }) + window.addEventListener('pagehide', unsubscribe, { once: true }) + const initial = await api.backend.status() + if (!changed) render(initial) + async function recover(operation) { + render({ phase: 'starting' }) + try { await operation() } + catch (error) { + const current = await api.backend.status().catch(() => undefined) + render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) }) + } + } + document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) }) + document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) }) + document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) }) +} + +void main() diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index 423e017b15..add677f668 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -6,8 +6,8 @@ export interface DesktopTargetBuildPaths { readonly artifacts: string readonly runtime: string readonly packageSet: string - readonly seed: string - readonly seedPnpm: string + readonly dsh: string + readonly dshPnpm: string readonly nodeExtract: string readonly packedDsh: string readonly packedVendor: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 504136e1db..770b3333e3 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -31,7 +31,7 @@ export function resolveDesktopBuildTarget( /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, seed: string, seedPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, nodeExtract: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { if (!SUPPORTED_TARGETS.has(target)) { @@ -44,8 +44,8 @@ export function desktopTargetBuildPaths(target) { artifacts: join(root, 'artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), - seed: join(root, 'seed'), - seedPnpm: join(root, 'seed-pnpm'), + dsh: join(root, 'dsh'), + dshPnpm: join(root, 'dsh-pnpm'), nodeExtract: join(root, 'node-extract'), packedDsh: join(packed, 'dsh'), packedVendor: join(packed, 'vendor'), diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 3ae65d12aa..85bea68633 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -53,7 +53,7 @@ async function runPackageScript(script: string, cwd: string): Promise { await run(process.execPath, [packageManager, 'run', script], cwd) } -async function launchElectron(projectDir: string): Promise { +async function launchElectron(): Promise { const require = createRequire(import.meta.url) const electron: unknown = require('electron') if (typeof electron !== 'string') throw new Error('desktop development: electron executable is unavailable') @@ -65,7 +65,6 @@ async function launchElectron(projectDir: string): Promise { const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, - DSH_DESKTOP_DEV_PROJECT_DIR: projectDir, DSH_DESKTOP_HOST_INSPECT_PORT: String(hostPort), DSH_DESKTOP_NODE_BINARY: process.execPath, DSH_DESKTOP_OPEN_DEVTOOLS: process.env.DSH_DESKTOP_OPEN_DEVTOOLS ?? '1', @@ -102,14 +101,14 @@ async function main(): Promise { nodeVersion: process.versions.node, pnpmVersion, } - const projectDir = prepareDevelopmentProject({ + prepareDevelopmentProject({ projectDir: join(DEVELOPMENT_ROOT, 'project'), cliDir: join(REPOSITORY_ROOT, 'apps', 'cli'), hostDir: join(REPOSITORY_ROOT, 'apps', 'desktop-host'), dependencyDir: join(REPOSITORY_ROOT, 'node_modules', '.pnpm', 'node_modules'), release, }) - await launchElectron(projectDir) + await launchElectron() } main().catch((error: unknown) => { diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh new file mode 100644 index 0000000000..3bd8622c72 --- /dev/null +++ b/apps/desktop/scripts/installer.nsh @@ -0,0 +1,17 @@ +!include "LogicLib.nsh" + +!macro customInstall + Push $0 + StrCpy $0 0 + ${If} ${Errors} + StrCpy $0 1 + ${EndIf} + ; Finish can launch the app while NSIS removes its remaining plugin directory. + RMDir /r "$PLUGINSDIR\7z-out" + ${If} $0 == 1 + SetErrors + ${Else} + ClearErrors + ${EndIf} + Pop $0 +!macroend diff --git a/apps/desktop/scripts/macos-runtime.ts b/apps/desktop/scripts/macos-runtime.ts new file mode 100644 index 0000000000..313c9cdf11 --- /dev/null +++ b/apps/desktop/scripts/macos-runtime.ts @@ -0,0 +1,43 @@ +/** Sign final native runtime files before the enclosing Desktop application is signed. */ + +import { createHash } from 'node:crypto' +import { closeSync, openSync, readSync } from 'node:fs' +import { join } from 'node:path' +import { inventoryDesktopRuntime } from '../src/runtime-tree.ts' +import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' +import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from './verify-macos-signature.mjs' + +const MACH_O_MAGICS = new Set(['cafebabe', 'cafebabf', 'cefaedfe', 'cffaedfe', 'feedface', 'feedfacf', 'bebafeca', 'bfbafeca']) + +function isMachO(path: string): boolean { + const descriptor = openSync(path, 'r') + try { + const header = Buffer.alloc(4) + return readSync(descriptor, header, 0, 4, 0) === 4 && MACH_O_MAGICS.has(header.toString('hex')) + } finally { closeSync(descriptor) } +} + +/** + * Sign and verify every materialized Mach-O file, awaiting all signers on failure. + * @param root - Self-contained production runtime without symlinks. + * @param appId - Release application identifier. + * @param expected - Required signing identity. + * @returns Number of signed native files. + */ +export async function signMacOSRuntime(root: string, appId: string, expected: MacOSSigningEnvironment): Promise { + const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => isMachO(join(root, path))) + let next = 0 + const workers = Array.from({ length: Math.min(4, files.length) }, async () => { + for (;;) { + const path = files[next++] + if (path === undefined) return + const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}` + await signMacOSRuntimeCode(join(root, path), identifier, expected) + verifyMacOSRuntimeCode(join(root, path), expected) + } + }) + const results = await Promise.allSettled(workers) + const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown) + if (errors.length > 0) throw new AggregateError(errors, 'desktop runtime: native signing failed') + return files.length +} diff --git a/apps/desktop/scripts/macos-seed-store.ts b/apps/desktop/scripts/macos-seed-store.ts deleted file mode 100644 index 1a02dfd5cc..0000000000 --- a/apps/desktop/scripts/macos-seed-store.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** Sign Mach-O content in a pnpm CAS without invalidating the store index. */ - -import { createHash } from 'node:crypto' -import { - chmodSync, - closeSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - openSync, - readFileSync, - readSync, - readdirSync, - rmSync, - unlinkSync, - writeFileSync, -} from 'node:fs' -import { availableParallelism, tmpdir } from 'node:os' -import { basename, dirname, join, relative, sep } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { Packr } from 'msgpackr' -import type { MacOSSigningEnvironment } from './desktop-release-environment.mjs' -import { signMacOSSeedCode, verifyMacOSSeedCode } from './verify-macos-signature.mjs' - -const MACH_O_MAGICS = new Set([ - 'cafebabe', - 'cafebabf', - 'cefaedfe', - 'cffaedfe', - 'feedface', - 'feedfacf', - 'bebafeca', - 'bfbafeca', -]) -const CAS_PATH_PATTERN = /^([0-9a-f]{2})\/([0-9a-f]{126})(-exec)?$/u -const MAX_CONCURRENT_CODE_SIGNERS = 4 -const packr = new Packr({ moreTypes: true, useRecords: true }) - -interface PnpmStoreFileRecord { - checkedAt: number - digest: string - mode: number - size: number -} - -interface PnpmSideEffectsRecord { - readonly added?: Map -} - -interface PnpmPackageIndexRecord { - readonly algo?: string - readonly files?: Map - readonly sideEffects?: Map -} - -interface DecodedIndexRow { - readonly key: string - readonly value: PnpmPackageIndexRecord - changed: boolean -} - -interface CasFile { - readonly path: string - readonly digest: string - readonly executable: boolean -} - -interface FileReference { - readonly row: DecodedIndexRow - readonly record: PnpmStoreFileRecord -} - -interface SigningWork { - readonly file: CasFile - readonly references: readonly FileReference[] - readonly temporaryPath: string -} - -/** Summary of native code rewritten in one pnpm store. */ -export interface MacOSSeedStoreSigningResult { - readonly signedFiles: number - readonly prunedOrphans: number - readonly updatedIndexRows: number -} - -/** A signer used to make one writable Mach-O copy release-valid. */ -export type MacOSSeedCodeSigner = (path: string, identifier: string) => Promise - -/** A verifier used to check one Mach-O file after packaging transport. */ -export type MacOSSeedCodeVerifier = (path: string) => void - -/** Optional execution controls for seed-store code signing. */ -export interface MacOSSeedStoreSigningOptions { - readonly signer?: MacOSSeedCodeSigner - readonly concurrency?: number -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function isStoreFileRecord(value: unknown): value is PnpmStoreFileRecord { - if (!isRecord(value)) return false - return typeof value.checkedAt === 'number' - && typeof value.digest === 'string' - && /^[0-9a-f]{128}$/u.test(value.digest) - && Number.isSafeInteger(value.mode) - && Number.isSafeInteger(value.size) -} - -function packageFileMaps(value: unknown, key: string): readonly Map[] { - if (!isRecord(value)) throw new Error(`desktop seed signing: invalid pnpm index record ${key}`) - const record = value as PnpmPackageIndexRecord - if (record.algo !== undefined && record.algo !== 'sha512') { - throw new Error(`desktop seed signing: unsupported pnpm index algorithm in ${key}`) - } - const maps: Map[] = [] - if (record.files !== undefined) { - if (!(record.files instanceof Map)) throw new Error(`desktop seed signing: invalid pnpm file map in ${key}`) - maps.push(record.files) - } - if (record.sideEffects !== undefined) { - if (!(record.sideEffects instanceof Map)) { - throw new Error(`desktop seed signing: invalid pnpm side-effects map in ${key}`) - } - for (const effect of record.sideEffects.values()) { - if (!isRecord(effect)) throw new Error(`desktop seed signing: invalid pnpm side effect in ${key}`) - if (effect.added === undefined) continue - if (!(effect.added instanceof Map)) { - throw new Error(`desktop seed signing: invalid pnpm side-effect file map in ${key}`) - } - maps.push(effect.added) - } - } - for (const files of maps) { - for (const file of files.values()) { - if (!isStoreFileRecord(file)) throw new Error(`desktop seed signing: invalid pnpm file record in ${key}`) - } - } - return maps -} - -function isExecutableMode(mode: number): boolean { - return (mode & 0o111) !== 0 -} - -function referenceKey(digest: string, executable: boolean): string { - return `${digest}:${executable ? 'exec' : 'nonexec'}` -} - -function isMachO(path: string): boolean { - const descriptor = openSync(path, 'r') - try { - const header = Buffer.alloc(4) - return readSync(descriptor, header, 0, header.length, 0) === header.length - && MACH_O_MAGICS.has(header.toString('hex')) - } finally { - closeSync(descriptor) - } -} - -function visitFiles(root: string): readonly string[] { - const files: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error(`desktop seed signing: pnpm store contains a symbolic link: ${relative(root, path)}`) - } - if (entry.isDirectory()) visit(path) - else if (entry.isFile()) files.push(path) - else throw new Error(`desktop seed signing: unsupported pnpm store entry: ${relative(root, path)}`) - } - } - visit(root) - return files.sort((left, right) => left.localeCompare(right)) -} - -function versionRoots(storeRoot: string): readonly string[] { - return readdirSync(storeRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && /^v\d+$/u.test(entry.name)) - .map(entry => join(storeRoot, entry.name)) - .filter(root => existsSync(join(root, 'files'))) - .sort((left, right) => left.localeCompare(right)) -} - -function casFiles(versionRoot: string): readonly CasFile[] { - const filesRoot = join(versionRoot, 'files') - const result: CasFile[] = [] - for (const path of visitFiles(filesRoot)) { - if (!isMachO(path)) continue - const normalized = relative(filesRoot, path).split(sep).join('/') - const match = CAS_PATH_PATTERN.exec(normalized) - if (match === null) { - throw new Error(`desktop seed signing: Mach-O content has an unsupported pnpm CAS path: ${normalized}`) - } - result.push({ - path, - digest: `${match[1]}${match[2]}`, - executable: match[3] !== undefined, - }) - } - return result -} - -function readIndexRows(database: DatabaseSync): readonly DecodedIndexRow[] { - const rows: DecodedIndexRow[] = [] - for (const row of database.prepare('SELECT key, data FROM package_index').iterate() as Iterable<{ - key: string - data: Uint8Array - }>) { - rows.push({ key: row.key, value: packr.unpack(row.data) as PnpmPackageIndexRecord, changed: false }) - } - return rows -} - -function fileReferences(rows: readonly DecodedIndexRow[]): ReadonlyMap { - const references = new Map() - for (const row of rows) { - for (const files of packageFileMaps(row.value, row.key)) { - for (const record of files.values()) { - const key = referenceKey(record.digest, isExecutableMode(record.mode)) - const values = references.get(key) ?? [] - values.push({ row, record }) - references.set(key, values) - } - } - } - return references -} - -function writeCasFile(path: string, body: Buffer, mode: number): void { - mkdirSync(dirname(path), { recursive: true }) - try { - writeFileSync(path, body, { flag: 'wx', mode }) - } catch (error) { - if (!isRecord(error) || error.code !== 'EEXIST' || !readFileSync(path).equals(body)) throw error - } - chmodSync(path, mode) -} - -function signedCasPath(versionRoot: string, digest: string, executable: boolean): string { - return join( - versionRoot, - 'files', - digest.slice(0, 2), - `${digest.slice(2)}${executable ? '-exec' : ''}`, - ) -} - -async function runConcurrent( - values: readonly T[], - concurrency: number, - run: (value: T) => Promise, -): Promise { - let next = 0 - const failure: { error?: unknown; failed: boolean } = { failed: false } - const worker = async (): Promise => { - while (!failure.failed) { - const index = next - if (index >= values.length) return - next += 1 - try { - await run(values[index] as T) - } catch (error) { - if (!failure.failed) { - failure.failed = true - failure.error = error - } - } - } - } - const workers = Array.from( - { length: Math.min(concurrency, values.length) }, - async () => worker(), - ) - await Promise.all(workers) - if (failure.failed) throw failure.error -} - -async function rewriteVersionStore( - versionRoot: string, - appId: string, - signer: MacOSSeedCodeSigner, - concurrency: number, -): Promise { - const databasePath = join(versionRoot, 'index.db') - if (!existsSync(databasePath)) { - throw new Error(`desktop seed signing: pnpm store has no package index: ${databasePath}`) - } - const database = new DatabaseSync(databasePath) - const workRoot = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-')) - const obsoleteFiles = new Set() - let prunedOrphans = 0 - let rows: readonly DecodedIndexRow[] = [] - try { - rows = readIndexRows(database) - const references = fileReferences(rows) - const signingWork: SigningWork[] = [] - for (const file of casFiles(versionRoot)) { - const body = readFileSync(file.path) - const actualDigest = createHash('sha512').update(body).digest('hex') - if (actualDigest !== file.digest) { - throw new Error(`desktop seed signing: pnpm CAS digest mismatch at ${file.path}`) - } - const fileReferences = references.get(referenceKey(file.digest, file.executable)) ?? [] - if (fileReferences.length === 0) { - obsoleteFiles.add(file.path) - prunedOrphans += 1 - continue - } - const temporary = join(workRoot, `${signingWork.length.toString().padStart(4, '0')}-${basename(file.path)}`) - copyFileSync(file.path, temporary) - chmodSync(temporary, 0o755) - signingWork.push({ file, references: fileReferences, temporaryPath: temporary }) - } - await runConcurrent(signingWork, concurrency, async (work) => { - await signer(work.temporaryPath, `${appId}.seed.${work.file.digest.slice(0, 32)}`) - }) - for (const work of signingWork) { - const signedBody = readFileSync(work.temporaryPath) - if (!isMachO(work.temporaryPath)) { - throw new Error(`desktop seed signing: signer produced non-Mach-O content for ${work.file.path}`) - } - const signedDigest = createHash('sha512').update(signedBody).digest('hex') - const mode = work.file.executable ? 0o755 : 0o644 - const destination = signedCasPath(versionRoot, signedDigest, work.file.executable) - writeCasFile(destination, signedBody, mode) - const checkedAt = Date.now() - for (const reference of work.references) { - reference.record.checkedAt = checkedAt - reference.record.digest = signedDigest - reference.record.mode = mode - reference.record.size = signedBody.length - reference.row.changed = true - } - if (destination !== work.file.path) obsoleteFiles.add(work.file.path) - } - const changedRows = rows.filter(row => row.changed) - database.exec('BEGIN IMMEDIATE') - let committed = false - try { - const statement = database.prepare('INSERT OR REPLACE INTO package_index (key, data) VALUES (?, ?)') - for (const row of changedRows) statement.run(row.key, packr.pack(row.value)) - database.exec('COMMIT') - committed = true - } finally { - if (!committed) database.exec('ROLLBACK') - } - for (const path of obsoleteFiles) unlinkSync(path) - database.exec('VACUUM') - return { signedFiles: signingWork.length, prunedOrphans, updatedIndexRows: changedRows.length } - } finally { - database.close() - rmSync(workRoot, { recursive: true, force: true }) - } -} - -/** - * Replace every Mach-O CAS object with a Developer ID signed object and update pnpm's SHA-512 index. - * A signer rejection leaves the original CAS objects and package index unchanged. - * @param storeRoot - Loose pnpm store prepared for the packaged seed. - * @param appId - Electron application ID used as the signing identifier prefix. - * @param expected - Company Developer ID identity and Team ID. - * @param options - Optional signer and worker bound used by focused tests. - * @returns Counts for release diagnostics after every signer completes and the index transaction commits. - */ -export async function signMacOSSeedStore( - storeRoot: string, - appId: string, - expected: MacOSSigningEnvironment, - options: MacOSSeedStoreSigningOptions = {}, -): Promise { - const roots = versionRoots(storeRoot) - if (roots.length === 0) throw new Error(`desktop seed signing: no pnpm store versions found in ${storeRoot}`) - const concurrency = options.concurrency ?? Math.min(MAX_CONCURRENT_CODE_SIGNERS, availableParallelism()) - if (!Number.isSafeInteger(concurrency) || concurrency < 1) { - throw new Error(`desktop seed signing: concurrency must be a positive integer; received ${String(concurrency)}`) - } - const signer = options.signer ?? (async (path, identifier) => { - await signMacOSSeedCode(path, identifier, expected) - }) - const results: MacOSSeedStoreSigningResult[] = [] - for (const root of roots) results.push(await rewriteVersionStore(root, appId, signer, concurrency)) - return results.reduce((total, current) => ({ - signedFiles: total.signedFiles + current.signedFiles, - prunedOrphans: total.prunedOrphans + current.prunedOrphans, - updatedIndexRows: total.updatedIndexRows + current.updatedIndexRows, - }), { signedFiles: 0, prunedOrphans: 0, updatedIndexRows: 0 }) -} - -/** - * Verify that every Mach-O CAS object has the expected Developer ID, timestamp, and hardened runtime. - * @param storeRoot - Loose or extracted pnpm store. - * @param expected - Company Developer ID identity and Team ID. - * @param verifier - Injectable signature verifier used by focused tests. - * @returns Number of verified Mach-O files. - */ -export function verifyMacOSSeedStore( - storeRoot: string, - expected: MacOSSigningEnvironment, - verifier: MacOSSeedCodeVerifier = (path) => { verifyMacOSSeedCode(path, expected) }, -): number { - let count = 0 - for (const root of versionRoots(storeRoot)) { - for (const file of casFiles(root)) { - verifier(file.path) - count += 1 - } - } - return count -} diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts new file mode 100644 index 0000000000..f1378d55ef --- /dev/null +++ b/apps/desktop/scripts/package-macos.ts @@ -0,0 +1,122 @@ +/** Build the ZIP and DMG from separate signed application copies with overlapping notarization. */ + +import { execFile } from 'node:child_process' +import { mkdtemp, rename, rm, stat } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { promisify } from 'node:util' +import { Arch, getArchSuffix } from 'electron-builder' +import { notarize } from '@electron/notarize' +import { + resolveMacOSNotarizationEnvironment, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs' +import { verifyMacOSNotarizedApplication, verifyMacOSSignature } from './verify-macos-signature.mjs' + +const execute = promisify(execFile) + +/** One electron-builder artifact made from an already signed application. */ +export interface DesktopPrepackagedArtifact { + readonly format: 'dmg' | 'zip' + readonly appPath: string + readonly output: string +} + +/** A signed macOS directory build and its final release destination. */ +export interface MacOSArtifactRequest { + readonly arch: 'arm64' | 'x64' + readonly version: string + readonly artifactsRoot: string + readonly environment: NodeJS.ProcessEnv +} + +/** Apple-tool operations replaced by deterministic fixtures in orchestration tests. */ +export interface MacOSArtifactOperations { + readonly copyApp: (source: string, destination: string) => Promise + readonly notarize: (options: ReturnType & { appPath: string }) => Promise + readonly verifySignature: typeof verifyMacOSSignature + readonly verifyNotarization: typeof verifyMacOSNotarizedApplication +} + +const operations: MacOSArtifactOperations = { + async copyApp(source, destination) { + await execute('/usr/bin/ditto', [source, destination]) + }, + notarize, + verifySignature: verifyMacOSSignature, + verifyNotarization: verifyMacOSNotarizedApplication, +} + +async function timed(label: string, action: () => Promise): Promise { + const start = performance.now() + process.stdout.write(`desktop macOS packaging: ${label} started at ${new Date().toISOString()}\n`) + await action() + process.stdout.write(`desktop macOS packaging: ${label} completed in ${((performance.now() - start) / 1000).toFixed(2)}s\n`) +} + +/** + * Notarize independent App/DMG copies concurrently, then promote their completed artifacts. + * Both lanes settle before cleanup or rejection. The ZIP contains a stapled App; the DMG + * carries its own ticket and encloses the signed App without an individually stapled ticket. + * @param request - Signed directory build, release version, architecture, and credentials. + * @param build - Runs electron-builder with publishing disabled; resolves only after its DMG + * notarization and verification hook succeeds, and rejects on build or hook failure. + * @param apple - Apple signing, copying, and notarization operations. + * @returns Resolves after both qualified payloads, ZIP metadata, and the stapled App are in the final directory. + */ +export async function packageMacOSArtifacts( + request: MacOSArtifactRequest, + build: (artifact: DesktopPrepackagedArtifact) => Promise, + apple: MacOSArtifactOperations = operations, +): Promise { + const { arch, version, artifactsRoot, environment } = request + const expected = resolveMacOSSigningEnvironment(environment) + const credentials = resolveMacOSNotarizationEnvironment(environment) + const appPath = join(artifactsRoot, `mac${getArchSuffix(Arch[arch])}`, 'DeepSeek Harness.app') + const root = await mkdtemp(join(dirname(artifactsRoot), 'notarization-')) + const zipApp = join(root, 'zip', basename(appPath)) + const dmgApp = join(root, 'dmg', basename(appPath)) + const zipOutput = join(root, 'zip-artifacts') + const dmgOutput = join(root, 'dmg-artifacts') + try { + await apple.copyApp(appPath, zipApp) + await apple.copyApp(appPath, dmgApp) + apple.verifySignature(zipApp, expected) + apple.verifySignature(dmgApp, expected) + const results = await Promise.allSettled([ + timed('App notarization and ZIP', async () => { + await apple.notarize({ appPath: zipApp, ...credentials }) + apple.verifyNotarization(zipApp, expected) + await build({ format: 'zip', appPath: zipApp, output: zipOutput }) + }), + timed('DMG creation and notarization', async () => { + await build({ format: 'dmg', appPath: dmgApp, output: dmgOutput }) + }), + ]) + const failures = results.filter(result => result.status === 'rejected') + if (failures.length > 0) { + throw new AggregateError(failures.map(result => result.reason), 'desktop macOS packaging: artifact lanes failed') + } + const base = `deepseek-harness-${version}-mac-${arch}` + const artifacts = [ + [dmgOutput, `${base}.dmg`], + [zipOutput, `${base}.zip`], + [zipOutput, `${base}.zip.blockmap`], + [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')], + ] as const + for (const [output, filename] of artifacts) { + const file = join(output, filename) + const details = await stat(file) + if (!details.isFile() || details.size === 0) { + throw new Error(`desktop macOS packaging: missing or empty artifact ${file}`) + } + } + for (const [output, filename] of artifacts) { + await rename(join(output, filename), join(artifactsRoot, filename)) + } + await rm(appPath, { recursive: true }) + await rename(zipApp, appPath) + } finally { + await rm(root, { recursive: true, force: true }) + } +} diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 64855151d4..22c303d2bc 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -1,4 +1,4 @@ -/** Build one release target with matching Electron, Node.js, and seed architecture. */ +/** Build one release target with matching Electron, Node.js, and dsh architecture. */ import { spawn } from 'node:child_process' import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' @@ -9,6 +9,7 @@ import { resolveDesktopAutoUpdateConfig, } from './desktop-auto-update-environment.mjs' import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts' const APP_ROOT = resolve(import.meta.dirname, '..') const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..') @@ -72,6 +73,25 @@ export function withoutWindowsSigningEnvironment(environment: NodeJS.ProcessEnv) .filter(([name]) => !name.startsWith(WINDOWS_SIGNING_ENV_PREFIX))) } +/** + * Select signing and NSIS-compatible archive filters for electron-builder. + * @param environment - Target packaging environment. + * @param unsigned - Whether to create a local unsigned Windows artifact. + * @returns Packaging environment without certificate inputs for unsigned builds. + */ +export function desktopElectronBuilderEnvironment(environment: NodeJS.ProcessEnv, unsigned: boolean): NodeJS.ProcessEnv { + const selected: NodeJS.ProcessEnv = { ...environment, DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0' } + // The bundled NSIS decoder cannot extract 7-Zip's automatic ARM64-filtered entries. + if (environment.DSH_DESKTOP_TARGET_PLATFORM === 'win32') selected.ELECTRON_BUILDER_7Z_FILTER = 'BCJ' + if (!unsigned) return selected + return { + ...Object.fromEntries(Object.entries(withoutWindowsSigningEnvironment(selected)) + .filter(([name]) => !/^(?:WIN_)?CSC_/iu.test(name))), + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + DSH_DESKTOP_UNSIGNED: '1', + } +} + /** * Remove upload-only COS credentials from every packaging subprocess. * @param environment - Packaging command environment. @@ -152,6 +172,7 @@ interface DesktopPackageInvocation { readonly target: DesktopPackageTarget readonly directory: boolean readonly prepareOnly: boolean + readonly unsigned: boolean } function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName { @@ -178,14 +199,18 @@ export function parseDesktopPackageInvocation( options: { dir: { type: 'boolean', default: false }, 'prepare-only': { type: 'boolean', default: false }, + unsigned: { type: 'boolean', default: false }, }, }) if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) + if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64') + if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') return { target: resolveDesktopPackageTarget(name, hostPlatform, hostArch), directory: values.dir, prepareOnly: values['prepare-only'], + unsigned: values.unsigned, } } @@ -193,11 +218,13 @@ export function parseDesktopPackageInvocation( * Build the electron-builder command arguments for one validated target. * @param target - Supported release target. * @param directory - Whether to stop at an unpacked application directory. + * @param artifact - Optional single artifact built from an existing signed application. * @returns Arguments that keep publishing under the separate validated upload command. */ export function desktopElectronBuilderArguments( target: DesktopPackageTarget, directory: boolean, + artifact?: DesktopPrepackagedArtifact, ): readonly string[] { return [ 'exec', @@ -205,10 +232,16 @@ export function desktopElectronBuilderArguments( '--config', 'electron-builder.config.mjs', target.builderPlatform, + ...(artifact === undefined ? [] : [artifact.format]), target.builderArch, '--publish', 'never', ...(directory ? ['--dir'] : []), + ...(artifact === undefined ? [] : [ + ...(target.platform === 'darwin' ? ['--config.mac.notarize=false'] : []), + '--prepackaged', artifact.appPath, + '--config.directories.output', artifact.output, + ]), ] } @@ -240,7 +273,7 @@ async function main(): Promise { const { target } = invocation const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) - if (!invocation.prepareOnly) { + if (!invocation.prepareOnly && !invocation.unsigned) { rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } @@ -250,9 +283,9 @@ async function main(): Promise { DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } - const electronBuilderEnv = { ...targetEnv } + const electronBuilderEnv = desktopElectronBuilderEnvironment(targetEnv, invocation.unsigned) for (const name of WINDOWS_SIGNING_ENV_NAMES) { - if (process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] + if (!invocation.unsigned && process.env[name] !== undefined) electronBuilderEnv[name] = process.env[name] } await runPnpm(['run', 'build:official'], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'release:pack', '--family', 'dsh', '--out', buildPaths.packedDsh], buildEnv, REPOSITORY_ROOT) @@ -276,10 +309,23 @@ async function main(): Promise { ], buildEnv, REPOSITORY_ROOT) await runPnpm(['run', 'prepare:runtime'], targetEnv) await runPnpm(['run', 'prepare:packages'], targetEnv) - await runPnpm(['run', 'prepare:seed'], targetEnv) + await runPnpm(['run', 'prepare:dsh'], targetEnv) if (invocation.prepareOnly) return - await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) - if (!invocation.directory) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) + if (target.platform === 'darwin' && !invocation.directory) { + await runPnpm([ + ...desktopElectronBuilderArguments(target, true), + '--config.mac.notarize=false', + ], electronBuilderEnv) + await packageMacOSArtifacts({ + arch: target.arch, + version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), + artifactsRoot: buildPaths.artifacts, + environment: electronBuilderEnv, + }, artifact => runPnpm(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)) + } else { + await runPnpm(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv) + } + if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts new file mode 100644 index 0000000000..d3f9dae8a3 --- /dev/null +++ b/apps/desktop/scripts/prepare-dsh.ts @@ -0,0 +1,159 @@ +/** Materialize the complete production runtime before publishing Desktop resources. */ + +import { spawn, execFile } from 'node:child_process' +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, dirname, join, relative, resolve } from 'node:path' +import { createRuntimeProjectMetadata } from '../src/project-manager.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' +import { + DESKTOP_HOST_PACKAGE, + DESKTOP_HOST_RUNTIME_FILES, + DESKTOP_PACKAGES_DIR, + DESKTOP_PACKAGE_SET_FILE, + readDesktopCorePackageSet, + verifyDesktopCoreLockfile, +} from '../src/core-package-set.ts' +import { smokeDesktopRuntime } from './smoke-runtime.ts' +import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { + resolveDesktopAppId, + resolveMacOSSigningEnvironment, +} from './desktop-release-environment.mjs' +import { + signMacOSRuntime, +} from './macos-runtime.ts' +import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts' + +const APP_ROOT = resolve(import.meta.dirname, '..') +const BUILD_PATHS = resolveDesktopTargetBuildPaths() +const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh +const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-')) +const STORE_ROOT = join(BUILD_ROOT, 'store') +const RUNTIME_ROOT = BUILD_PATHS.runtime +const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm +const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet +const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') +const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') + +function manifestVersion(path: string, subject: string): string { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } + if (typeof manifest.version !== 'string') throw new Error(`desktop runtime: ${subject} has no version`) + return manifest.version +} + +function desktopRelease(): DesktopRelease { + const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') + const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') + if (version !== dshVersion) { + throw new Error(`desktop runtime: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) + } + const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record + return parseDesktopRelease({ + schemaVersion: 1, + version, + hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, + nodeVersion: runtime.node, + pnpmVersion: runtime.pnpm, + }) +} + +function runPnpm(args: readonly string[]): Promise { + return new Promise((resolvePromise, reject) => { + const [command, ...commandArgs] = args + if (command === undefined) throw new Error('desktop runtime: pnpm command is required') + const config = join(PNPM_BUILD_STATE, 'config') + const userConfig = join(config, 'npmrc') + mkdirSync(config, { recursive: true }) + writeFileSync(userConfig, '') + const child = spawn(NODE, [ + PNPM, + '--config.registry=https://registry.npmjs.org/', + `--config.store-dir=${STORE_ROOT}`, + '--config.enable-global-virtual-store=false', + `--config.userconfig=${userConfig}`, + command, + ...commandArgs, + ], { + cwd: BUILD_ROOT, + env: { + ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + ))), + NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', + NPM_CONFIG_STORE_DIR: STORE_ROOT, + NPM_CONFIG_USERCONFIG: userConfig, + PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, + XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), + XDG_CONFIG_HOME: config, + XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), + }, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolvePromise() + else reject(new Error(`desktop runtime: pnpm exited with ${String(code ?? signal)}`)) + }) + }) +} + +async function main(): Promise { + rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + mkdirSync(STORE_ROOT, { recursive: true }) + try { + const release = desktopRelease() + copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE)) + cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) + createRuntimeProjectMetadata(BUILD_ROOT, release) + await runPnpm(['install', '--lockfile-only']) + verifyDesktopCoreLockfile( + readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'), + readDesktopCorePackageSet(BUILD_ROOT, release.version), + ) + await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']) + const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version) + const targetName = resolveDesktopBuildTarget() + const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' } + const modules = join(BUILD_ROOT, 'node_modules') + mkdirSync(DSH_OUTPUT_ROOT, { recursive: true }) + cpSync(modules, join(DSH_OUTPUT_ROOT, 'node_modules'), { + recursive: true, dereference: true, + filter: source => desktopRuntimeFileExclusion(relative(modules, source), target) === undefined, + }) + writeFileSync(join(DSH_OUTPUT_ROOT, 'package.json'), `${JSON.stringify({ + name: '@deepseek-ai/dsh-desktop-runtime', private: true, version: release.version, type: 'module', + dependencies: Object.fromEntries(packageSet.packages.map(entry => [entry.name, entry.version])), + }, undefined, 2)}\n`) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + if (!existsSync(join(DSH_OUTPUT_ROOT, 'node_modules', DESKTOP_HOST_PACKAGE, file))) { + throw new Error(`desktop runtime: missing private Host file ${file}`) + } + } + if (process.platform === 'darwin') { + await signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env)) + } + writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target) + const descriptor = await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + await new Promise((accept, reject) => { + execFile(NODE, [join(APP_ROOT, 'tests/fixtures/runtime-payload-smoke.mjs'), DSH_OUTPUT_ROOT], + { timeout: 120_000, env: { ...process.env, NODE_OPTIONS: '' } }, (error, stdout, stderr) => { + if (error !== null) reject(new Error(`desktop native payload smoke failed: ${stderr}`, { cause: error })) + else { process.stdout.write(stdout); accept() } + }) + }) + await smokeDesktopRuntime(DSH_OUTPUT_ROOT, NODE, descriptor) + await verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target) + } catch (error) { + rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true }) + throw error + } finally { + rmSync(BUILD_ROOT, { recursive: true, force: true }) + rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) + } +} + +await main() diff --git a/apps/desktop/scripts/prepare-seed.ts b/apps/desktop/scripts/prepare-seed.ts deleted file mode 100644 index 73da078ef2..0000000000 --- a/apps/desktop/scripts/prepare-seed.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** Build the release seed through the same embedded pnpm used on first launch. */ - -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { delimiter, dirname, join, relative, resolve, sep } from 'node:path' -import { createSeedMetadata } from '../src/project-manager.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { parseDesktopRelease, type DesktopRelease } from '../src/release.ts' -import { - DESKTOP_HOST_PACKAGE, - DESKTOP_HOST_RUNTIME_FILES, - DESKTOP_PACKAGES_DIR, - DESKTOP_PACKAGE_SET_FILE, - readDesktopCorePackageSet, - verifyDesktopCoreLockfile, -} from '../src/core-package-set.ts' -import { - archivePnpmStore, - extractPnpmStoreArchives, - removePnpmProjectRegistrations, -} from '../src/seed-store.ts' -import { - resolveDesktopAppId, - resolveMacOSSigningEnvironment, -} from './desktop-release-environment.mjs' -import { - signMacOSSeedStore, - verifyMacOSSeedStore, -} from './macos-seed-store.ts' -import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' - -const APP_ROOT = resolve(import.meta.dirname, '..') -const BUILD_PATHS = resolveDesktopTargetBuildPaths() -const SEED_OUTPUT_ROOT = BUILD_PATHS.seed -const SEED_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-')) -const STORE_ROOT = join(SEED_ROOT, 'store') -const RUNTIME_ROOT = BUILD_PATHS.runtime -const PNPM_BUILD_STATE = BUILD_PATHS.seedPnpm -const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet -const NODE = join(RUNTIME_ROOT, 'node', process.platform === 'win32' ? 'node.exe' : 'node') -const PNPM = join(RUNTIME_ROOT, 'pnpm', 'bin', 'pnpm.mjs') - -function manifestVersion(path: string, subject: string): string { - const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown } - if (typeof manifest.version !== 'string') throw new Error(`desktop seed: ${subject} has no version`) - return manifest.version -} - -function desktopRelease(): DesktopRelease { - const version = manifestVersion(join(APP_ROOT, 'package.json'), 'desktop package') - const dshVersion = manifestVersion(resolve(APP_ROOT, '..', '..', 'package.json'), 'root dsh package') - if (version !== dshVersion) { - throw new Error(`desktop seed: Electron ${version} must bind the same version of @deepseek-ai/dsh, found ${dshVersion}`) - } - const runtime = JSON.parse(readFileSync(join(RUNTIME_ROOT, 'versions.json'), 'utf8')) as Record - return parseDesktopRelease({ - schemaVersion: 1, - version, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - nodeVersion: runtime.node, - pnpmVersion: runtime.pnpm, - }) -} - -function runPnpm(args: readonly string[]): Promise { - return new Promise((resolvePromise, reject) => { - const [command, ...commandArgs] = args - if (command === undefined) throw new Error('desktop seed: pnpm command is required') - const config = join(PNPM_BUILD_STATE, 'config') - const userConfig = join(config, 'npmrc') - mkdirSync(config, { recursive: true }) - writeFileSync(userConfig, '') - const child = spawn(NODE, [ - PNPM, - '--config.registry=https://registry.npmjs.org/', - `--config.store-dir=${STORE_ROOT}`, - '--config.enable-global-virtual-store=false', - `--config.userconfig=${userConfig}`, - command, - ...commandArgs, - ], { - cwd: SEED_ROOT, - env: { - ...Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) - ))), - NPM_CONFIG_REGISTRY: 'https://registry.npmjs.org/', - NPM_CONFIG_STORE_DIR: STORE_ROOT, - NPM_CONFIG_USERCONFIG: userConfig, - PATH: `${dirname(NODE)}${delimiter}${process.env.PATH ?? ''}`, - XDG_CACHE_HOME: join(PNPM_BUILD_STATE, 'cache'), - XDG_CONFIG_HOME: config, - XDG_STATE_HOME: join(PNPM_BUILD_STATE, 'state'), - }, - stdio: 'inherit', - }) - child.once('error', reject) - child.once('close', (code, signal) => { - if (code === 0) resolvePromise() - else reject(new Error(`desktop seed: pnpm exited with ${String(code ?? signal)}`)) - }) - }) -} - -function inventory(root: string): readonly { path: string; bytes: number; sha256: string }[] { - const files: string[] = [] - const visit = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name) - if (entry.isDirectory()) visit(path) - else if (entry.isFile()) files.push(path) - else throw new Error(`desktop seed: unsupported filesystem entry ${relative(root, path)}`) - } - } - visit(root) - return files.sort().map((path) => { - const body = readFileSync(path) - return { - path: relative(root, path).split(sep).join('/'), - bytes: statSync(path).size, - sha256: createHash('sha256').update(body).digest('hex'), - } - }) -} - -async function verifyOfflineInstallation(release: DesktopRelease): Promise { - const installedModules = join(SEED_ROOT, 'node_modules') - try { - await runPnpm(['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - const hostRoot = join(installedModules, ...DESKTOP_HOST_PACKAGE.split('/')) - for (const file of DESKTOP_HOST_RUNTIME_FILES) { - if (!existsSync(join(hostRoot, file))) { - throw new Error(`desktop seed: local ${DESKTOP_HOST_PACKAGE}@${release.version} does not contain ${file}`) - } - } - } finally { - rmSync(installedModules, { recursive: true, force: true }) - } -} - -async function main(): Promise { - rmSync(SEED_OUTPUT_ROOT, { recursive: true, force: true }) - rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) - mkdirSync(STORE_ROOT, { recursive: true }) - try { - const release = desktopRelease() - copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(SEED_ROOT, DESKTOP_PACKAGE_SET_FILE)) - cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(SEED_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true }) - createSeedMetadata(SEED_ROOT, release) - await runPnpm(['install', '--lockfile-only']) - verifyDesktopCoreLockfile( - readFileSync(join(SEED_ROOT, 'pnpm-lock.yaml'), 'utf8'), - readDesktopCorePackageSet(SEED_ROOT, release.version), - ) - const installedModules = join(SEED_ROOT, 'node_modules') - await runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile', '--ignore-scripts']) - rmSync(installedModules, { recursive: true, force: true }) - rmSync(PNPM_BUILD_STATE, { recursive: true, force: true }) - await verifyOfflineInstallation(release) - const targetPlatform = process.env.DSH_DESKTOP_TARGET_PLATFORM ?? process.platform - let signedMachOFiles: number | undefined - let macOSSigning: ReturnType | undefined - if (targetPlatform === 'darwin') { - macOSSigning = resolveMacOSSigningEnvironment(process.env) - const signing = await signMacOSSeedStore( - STORE_ROOT, - resolveDesktopAppId(process.env), - macOSSigning, - ) - signedMachOFiles = signing.signedFiles - process.stdout.write( - `desktop seed: signed ${signing.signedFiles} Mach-O files, updated ${signing.updatedIndexRows} pnpm index records, and pruned ${signing.prunedOrphans} native orphans\n`, - ) - await verifyOfflineInstallation(release) - } - removePnpmProjectRegistrations(STORE_ROOT) - archivePnpmStore(SEED_ROOT, STORE_ROOT) - if (macOSSigning !== undefined && signedMachOFiles !== undefined) { - const extractedStore = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-verification-')) - try { - extractPnpmStoreArchives(SEED_ROOT, extractedStore) - const verified = verifyMacOSSeedStore(extractedStore, macOSSigning) - if (verified !== signedMachOFiles) { - throw new Error(`desktop seed: archived store contains ${verified} signed Mach-O files; expected ${signedMachOFiles}`) - } - } finally { - rmSync(extractedStore, { recursive: true, force: true }) - } - } - const records = inventory(SEED_ROOT).filter(entry => entry.path !== 'integrity.json') - writeFileSync(join(SEED_ROOT, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files: records }, undefined, 2)}\n`) - cpSync(SEED_ROOT, SEED_OUTPUT_ROOT, { recursive: true }) - } finally { - rmSync(SEED_ROOT, { recursive: true, force: true }) - } -} - -await main() diff --git a/apps/desktop/scripts/runtime-file-policy.ts b/apps/desktop/scripts/runtime-file-policy.ts new file mode 100644 index 0000000000..9e6722009d --- /dev/null +++ b/apps/desktop/scripts/runtime-file-policy.ts @@ -0,0 +1,40 @@ +/** Desktop-only omissions from already installed production npm packages. */ + +/** + * Identify build and diagnostic files omitted from the immutable Desktop runtime. + * Unrecognized assets and target runtime binaries are retained. Paths name the copied + * node_modules tree, including nested package containers. + * @param path - Path relative to the production node_modules directory. + * @param target - Platform and architecture of the bundled Node executable. + * @returns Omission reason, or undefined when the entry must be copied. + */ +export function desktopRuntimeFileExclusion( + path: string, target: { platform: NodeJS.Platform; arch: string }, +): string | undefined { + const parts = path.split(/[\\/]/u) + if (parts.some(part => ['.bin', '.pnpm', '.modules.yaml', '.pnpm-workspace-state-v1.json'].includes(part))) { + return 'package-manager metadata' + } + const file = parts.at(-1) ?? '' + if (/\.(?:[cm]?[jt]s|css)\.map$/u.test(file)) return 'source map' + if (/\.d\.[cm]?ts$/u.test(file)) return 'TypeScript declaration' + if (/\.tsbuildinfo$/u.test(file)) return 'TypeScript build cache' + const packageParts = parts.slice(parts.lastIndexOf('node_modules') + 1) + const nameParts = packageParts[0]?.startsWith('@') ? 2 : 1 + const name = packageParts.slice(0, nameParts).join('/') + const entry = packageParts.slice(nameParts).join('/') + if (name === 'fs-ext' && /^build\/(?:Release|Debug)\/(?:obj(?:\/|$)|fs_ext\.(?:exp|lib|pdb|iobj|ipdb)$)/u.test(entry)) { + return 'fs-ext compiler output' + } + if (name === 'fs-ext' && /^build\/(?:binding\.sln|config\.gypi|fs_ext\.vcxproj(?:\.filters)?)$/u.test(entry)) { + return 'fs-ext build configuration' + } + if (name === '@mixmark-io/domino' && (entry === 'test' || entry.startsWith('test/'))) return 'Domino test fixtures' + if (name === 'node-pty' && entry.startsWith('prebuilds/')) { + const platform = packageParts[nameParts + 1] + if (platform !== undefined && platform !== `${target.platform}-${target.arch}`) return 'node-pty other platform' + if (file.endsWith('.pdb')) return 'node-pty debug symbols' + } + if (name === '@koromix/koffi-win32-x64' && entry === 'win32_x64/koffi.lib') return 'Koffi import library' + return undefined +} diff --git a/apps/desktop/scripts/smoke-runtime.ts b/apps/desktop/scripts/smoke-runtime.ts new file mode 100644 index 0000000000..4b4103e534 --- /dev/null +++ b/apps/desktop/scripts/smoke-runtime.ts @@ -0,0 +1,58 @@ +/** Boot the materialized target runtime without access to a user's Harness profile. */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DesktopHostProcess } from '../src/host-process.ts' +import { createPluginProfile } from '../src/project-manager.ts' +import { linkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import type { DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' + +/** + * Prove the final resource tree boots and serves its matching Web frontend. + * @param root - Materialized dsh resources. + * @param node - Prepared target Node executable. + * @param runtime - Verified resource descriptor. + */ +export async function smokeDesktopRuntime(root: string, node: string, runtime: DesktopRuntimeDescriptor): Promise { + const home = mkdtempSync(join(tmpdir(), 'dsh-desktop-smoke-')) + const profile = join(home, 'profiles', 'desktop') + const host = new DesktopHostProcess(node, root, profile, undefined, { ...process.env, DSH_HOME: home }) + try { + createPluginProfile(profile) + const pluginName = 'desktop-runtime-smoke-plugin' + const plugin = join(profile, 'node_modules', pluginName) + mkdirSync(plugin, { recursive: true }) + const cordis = runtime.sharedPackages.find(entry => entry.name === '@deepseek-ai/cordis') + if (cordis === undefined) throw new Error('desktop runtime: missing shared Cordis package') + writeFileSync(join(plugin, 'package.json'), JSON.stringify({ + name: pluginName, version: '1.0.0', type: 'module', exports: './index.js', + peerDependencies: { '@deepseek-ai/cordis': cordis.version }, dsh: { bundle: { patch: './bundle.yml' } }, + })) + writeFileSync(join(plugin, 'index.js'), ` +import { Context } from '@deepseek-ai/cordis' +export function apply(ctx) { + if (!(ctx instanceof Context)) throw new Error('desktop runtime: external plugin loaded another Cordis instance') +} +`) + writeFileSync(join(plugin, 'bundle.yml'), '- insert:\n - id: desktop-runtime-smoke-plugin\n name: desktop-runtime-smoke-plugin\n') + const manifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { + dependencies: Record + dsh: { profile: { bundles: string[] } } + } + manifest.dependencies[pluginName] = '1.0.0' + manifest.dsh.profile.bundles.push(pluginName) + writeFileSync(join(profile, 'package.json'), JSON.stringify(manifest)) + linkDesktopHostPackages(profile, root, runtime) + validateDesktopPluginGraph(profile, root, runtime, [pluginName]) + const ready = await host.start() + if (ready.dshVersion !== runtime.release.version) throw new Error('desktop runtime: Host reported another dsh release') + const response = await host.fetch(new Request('dsh-app://app/')) + if (response.status !== 200 || !(await response.text()).includes(' /** - * Verify one Mach-O file embedded in the seed store. + * Verify one Mach-O file embedded in the runtime tree. * @param path - Mach-O file to inspect. * @param expected - Public release identity. */ -export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironment): void +export function verifyMacOSRuntimeCode(path: string, expected: MacOSSigningEnvironment): void /** * Verify the full application signature and its release owner. @@ -41,6 +41,13 @@ export function verifyMacOSSeedCode(path: string, expected: MacOSSigningEnvironm */ export function verifyMacOSSignature(appPath: string, expected: MacOSSigningEnvironment): void +/** + * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance. + * @param appPath - Path to the stapled `.app` directory. + * @param expected - Public release identity. + */ +export function verifyMacOSNotarizedApplication(appPath: string, expected: MacOSSigningEnvironment): void + /** * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. * @param diskImagePath - Path to the packaged `.dmg` file. diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 48b254dedd..13d881f2fc 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -1,4 +1,4 @@ -/** Sign seed code and verify that packaged macOS artifacts carry the company release identity. */ +/** Sign runtime code and verify that packaged macOS artifacts carry the company release identity. */ import { spawn, spawnSync } from 'node:child_process' import { resolve } from 'node:path' @@ -21,19 +21,19 @@ export function assertMacOSSignatureDetails(details, expected) { } /** - * Require the signature properties Apple validates for executable seed content. + * Require the signature properties Apple validates for executable runtime content. * @param {string} details - Output from `codesign --display --verbose=4`. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {void} */ -export function assertMacOSSeedSignatureDetails(details, expected) { +export function assertMacOSRuntimeSignatureDetails(details, expected) { assertMacOSSignatureDetails(details, expected) const fields = details.split(/\r?\n/u).map(line => line.trim()) if (!fields.some(line => /^Timestamp=.+/u.test(line))) { - throw new Error('desktop macOS signing: seed signature has no secure timestamp') + throw new Error('desktop macOS signing: runtime signature has no secure timestamp') } if (!fields.some(line => /\bflags=0x[0-9a-f]+\(runtime\)(?:\s|$)/iu.test(line))) { - throw new Error('desktop macOS signing: seed signature does not enable hardened runtime') + throw new Error('desktop macOS signing: runtime signature does not enable hardened runtime') } } @@ -60,7 +60,7 @@ function runAppleCommand(command, args, label) { } /** - * Execute one Apple release tool without blocking other independent seed signers. + * Execute one Apple release tool without blocking other independent runtime signers. * @param {string} command - Absolute executable path. * @param {readonly string[]} args - Tool arguments. * @param {string} label - Stable diagnostic name. @@ -106,13 +106,13 @@ function runCodeSign(args) { } /** - * Sign one Mach-O file embedded in the seed store. + * Sign one Mach-O file embedded in the runtime tree. * @param {string} path - Writable standalone Mach-O file. * @param {string} identifier - Stable code-signing identifier derived from the release app ID and CAS digest. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {Promise} Resolves after codesign exits successfully. */ -export async function signMacOSSeedCode(path, identifier, expected) { +export async function signMacOSRuntimeCode(path, identifier, expected) { await runAppleCommandAsync('/usr/bin/codesign', [ '--force', '--sign', expected.signingIdentity, @@ -124,15 +124,15 @@ export async function signMacOSSeedCode(path, identifier, expected) { } /** - * Verify one Mach-O file embedded in the seed store. + * Verify one Mach-O file embedded in the runtime tree. * @param {string} path - Mach-O file to inspect. * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. * @returns {void} */ -export function verifyMacOSSeedCode(path, expected) { +export function verifyMacOSRuntimeCode(path, expected) { runCodeSign(['--verify', '--strict', '--verbose=2', path]) const details = runCodeSign(['--display', '--verbose=4', path]) - assertMacOSSeedSignatureDetails(details, expected) + assertMacOSRuntimeSignatureDetails(details, expected) } /** @@ -147,6 +147,18 @@ export function verifyMacOSSignature(appPath, expected) { assertMacOSSignatureDetails(details, expected) } +/** + * Verify an independently distributed application's signature, ticket, and Gatekeeper acceptance. + * @param {string} appPath - Path to the stapled `.app` directory. + * @param {{ signingIdentity: string, teamId: string }} expected - Public release identity. + * @returns {void} + */ +export function verifyMacOSNotarizedApplication(appPath, expected) { + verifyMacOSSignature(appPath, expected) + runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', appPath], 'stapler validate') + runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath], 'spctl') +} + /** * Verify the release identity, stapled ticket, and Gatekeeper acceptance of one disk image. * @param {string} diskImagePath - Path to the packaged `.dmg` file. diff --git a/apps/desktop/src/backend-controller.ts b/apps/desktop/src/backend-controller.ts new file mode 100644 index 0000000000..648de03975 --- /dev/null +++ b/apps/desktop/src/backend-controller.ts @@ -0,0 +1,148 @@ +/** Owns one backend startup and its quiescent teardown independently of windows. */ + +import { desktopErrorState } from './startup-error.ts' + +/** Backend availability presented by the desktop window. */ +export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean } + +/** Child lifecycle owned by the desktop backend controller. */ +export interface DesktopBackendHost { + /** @returns Readiness after the child accepts application requests. */ + start(): Promise + /** @returns Completion of child exit. */ + stop(): Promise +} + +interface Attempt { + cancelled: boolean + failure?: Error + host?: Host + cleanup?: Promise +} + +/** Serializes retries and prevents children from outliving a closed window. */ +export class DesktopBackendController { + private current: DesktopBackendState = { phase: 'starting' } + private attempt: Attempt | undefined + private pending: Promise | undefined + private stopping: Promise | undefined + private closed = false + + /** + * @param createHost - Allocate a child and route its fatal failures to the supplied callback. + * @param publish - Receive availability changes until the controller closes. + */ + constructor( + private readonly createHost: (onFailure: (error: Error) => void) => Host, + private readonly publish: (state: DesktopBackendState) => void, + ) {} + + /** Current availability, including the last startup or child failure. */ + get state(): DesktopBackendState { return this.current } + + /** Child available to application requests; absent during startup and teardown. */ + get host(): Host | undefined { return !this.closed && !this.attempt?.cancelled && this.current.phase === 'ready' ? this.attempt?.host : undefined } + + /** + * Prepare the profile and start one child; concurrent callers share the attempt. + * @param prepare - Profile preparation that must finish before spawning. + * @returns Completion of startup, rejecting on preparation, startup, or cleanup failure. + */ + start(prepare: () => Promise): Promise { + if (this.closed) return Promise.reject(new Error('desktop backend is closed')) + if (this.stopping !== undefined) return Promise.reject(new Error('desktop backend is stopping')) + if (this.pending !== undefined) return this.pending + if (this.current.phase === 'ready') return Promise.resolve() + const previous = this.attempt + const attempt: Attempt = { cancelled: false, ...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }) } + this.attempt = attempt + this.update({ phase: 'starting' }) + const pending = Promise.resolve().then(async () => { + try { + await previous?.cleanup + delete attempt.cleanup + if (attempt.cancelled) return + await prepare() + // stop() can cancel this attempt while preparation is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (attempt.cancelled) return + const host = this.createHost((error) => { this.failed(attempt, error) }) + attempt.host = host + await host.start() + if (attempt.failure !== undefined) throw attempt.failure + // stop() can cancel this attempt while the child starts. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!attempt.cancelled) this.update({ phase: 'ready' }) + } catch (error) { + const cancelled = attempt.cancelled + attempt.cancelled = true + let failure = error + try { await this.cleanup(attempt) } catch (cleanupError) { + if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed') + } + if (!cancelled) this.update(desktopErrorState(failure)) + throw failure + } + }).finally(() => { if (this.pending === pending) this.pending = undefined }) + this.pending = pending + return pending + } + + /** + * Stop pending preparation and the child before allowing another start. + * @returns Completion of pending work and child exit; rejects if cleanup fails. + */ + stop(): Promise { + if (this.stopping !== undefined) return this.stopping + const attempt = this.attempt + if (attempt !== undefined) attempt.cancelled = true + if (!this.closed) this.update({ phase: 'starting' }) + const pending = this.pending + const stopping = Promise.allSettled([ + attempt === undefined ? Promise.resolve() : this.cleanup(attempt), + pending, + ]).then((results) => { + const cleanup = results[0] + if (cleanup.status === 'rejected') throw cleanup.reason + if (this.attempt === attempt) this.attempt = undefined + }).finally(() => { if (this.stopping === stopping) this.stopping = undefined }) + this.stopping = stopping + return stopping + } + + /** + * Permanently prevent startup and suppress further availability notifications. + * @returns Completion of pending work and child exit; rejects if cleanup fails. + */ + close(): Promise { + this.closed = true + return this.stop() + } + + private cleanup(attempt: Attempt): Promise { + if (attempt.cleanup === undefined) { + attempt.cleanup = Promise.resolve().then(async () => { await attempt.host?.stop() }) + } + return attempt.cleanup + } + + private failed(attempt: Attempt, error: Error): void { + if (this.attempt !== attempt || attempt.cancelled) return + attempt.failure = error + if (this.current.phase !== 'ready') return + attempt.cancelled = true + const cleanup = this.cleanup(attempt) + this.update(desktopErrorState(error)) + void cleanup.catch((cleanupError: unknown) => { + if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop'))) + }) + } + + private update(state: DesktopBackendState): void { + if (this.closed) return + this.current = state + try { this.publish(state) } catch (error) { + console.error('desktop backend state listener failed', error) + } + } +} diff --git a/apps/desktop/src/core-package-set.ts b/apps/desktop/src/core-package-set.ts index ff93bd5605..249057d647 100644 --- a/apps/desktop/src/core-package-set.ts +++ b/apps/desktop/src/core-package-set.ts @@ -124,7 +124,7 @@ export function desktopDshPackageSpec(packageSet: DesktopCorePackageSet): string /** * Verify every local tarball and reject extra package files before pnpm executes them. - * @param projectDir - Seed or profile directory containing the package set. + * @param projectDir - Build directory containing the package set. * @param expectedReleaseVersion - Exact dsh and Desktop Host version bound to Electron. * @returns The verified package set. */ diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index ed18d0c7d6..7442c71172 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -83,31 +83,39 @@ export class DesktopHostProcess { }) private exitPromise: Promise | undefined private stderr = '' + private failureReported = false /** * @param node - absolute bundled upstream Node.js executable. - * @param projectDir - active or staged desktop npm project. + * @param runtimeDir - immutable packages carried by the current application. + * @param projectDir - active or staged desktop plugin profile. * @param inspectPort - optional loopback inspector port for workspace development. + * @param environment - Child environment; runtime and package-manager overrides are removed. + * @param onFailure - Receives the first fatal child or transport failure, including after readiness. */ constructor( private readonly node: string, + private readonly runtimeDir: string, private readonly projectDir: string, private readonly inspectPort?: number, + private readonly environment: NodeJS.ProcessEnv = process.env, + private readonly onFailure?: (error: Error) => void, ) {} /** Start the child once and resolve only after its complete composition is active. */ async start(): Promise { if (this.child !== undefined) return this.readyPromise - const entry = join(this.projectDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') + const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js') const child = spawn(this.node, [ ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]), entry, + this.runtimeDir, this.projectDir, ...(this.inspectPort === undefined ? [] : ['--allow-linked-profile']), ], { cwd: this.projectDir, - env: Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - name !== 'NODE_OPTIONS' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + env: Object.fromEntries(Object.entries(this.environment).filter(([name]) => ( + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))), stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe', 'ipc'], }) @@ -144,7 +152,7 @@ export class DesktopHostProcess { }) child.once('error', (error) => { this.fail(error) }) this.exitPromise = new Promise((resolve) => { - child.once('exit', (code) => { + child.once('close', (code) => { const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}` if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`)) else this.fail(new Error(`dsh desktop host stopped${suffix}`)) @@ -273,7 +281,7 @@ export class DesktopHostProcess { private send(message: DesktopHostCommand): void { const child = this.child if (child === undefined || !child.connected) throw new Error('dsh desktop host IPC is unavailable') - child.send(message) + child.send(message, (error) => { if (error !== null) this.fail(error) }) } private acceptResponseBytes(chunk: Buffer): void { @@ -400,6 +408,12 @@ export class DesktopHostProcess { private fail(error: Error): void { this.readyReject(error) + if (!this.failureReported) { + this.failureReported = true + try { this.onFailure?.(error) } catch (listenerError) { + console.error('desktop host failure listener failed', listenerError) + } + } for (const pending of this.pending.values()) { void pending.requestReader?.cancel(error).catch(() => undefined) if (pending.controller === undefined) pending.reject(error) diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 4fb946a2a5..6883c978ac 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -2,6 +2,7 @@ import type { DesktopPluginRecord } from './project-manager.ts' import type { DesktopLocale } from './locale.ts' +import type { DesktopBackendState } from './backend-controller.ts' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { @@ -10,6 +11,13 @@ export const DESKTOP_IPC = { pluginsAdd: 'dsh-desktop:plugins-add', pluginsRemove: 'dsh-desktop:plugins-remove', pluginsUpdate: 'dsh-desktop:plugins-update', + pluginsToggle: 'dsh-desktop:plugins-toggle', + pluginsDisableAll: 'dsh-desktop:plugins-disable-all', + backendStatus: 'dsh-desktop:backend-status', + backendRetry: 'dsh-desktop:backend-retry', + applicationRestart: 'dsh-desktop:application-restart', + configurationReset: 'dsh-desktop:configuration-reset', + backendState: 'dsh-desktop:backend-state', updatesCheck: 'dsh-desktop:updates-check', updatesInstall: 'dsh-desktop:updates-install', updatesState: 'dsh-desktop:updates-state', @@ -31,6 +39,13 @@ export interface DshDesktopApi { add(spec: string): Promise remove(name: string): Promise update(name: string, version: string): Promise + toggle(name: string, enabled: boolean): Promise + disableAll(): Promise + } + readonly backend: { + status(): Promise + retry(): Promise + subscribe(listener: (state: DesktopBackendState) => void): () => void } readonly updates: { check(): Promise @@ -38,3 +53,11 @@ export interface DshDesktopApi { subscribe(listener: (state: DesktopUpdateState) => void): () => void } } + +/** Startup-page controls, unavailable to backend-provided application documents. */ +export interface DshDesktopStartupApi extends Pick { + readonly backend: Omit + disablePlugins(): Promise + restart(): Promise + resetConfiguration(): Promise +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index b666cfd559..e2e4d9e6c3 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -3,6 +3,14 @@ export const en = { application: 'Application', startupFailed: 'DeepSeek Harness could not start', + startupLoading: 'Starting DeepSeek Harness…', + startupLoadingDescription: 'Your workspace will open when it is ready.', + startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.', + startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.', + startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.', + restartApplication: 'Close and restart', + resetConfiguration: 'Reset Desktop and retry', + disableThirdPartyPlugins: 'Disable all third-party plugins and retry', pluginsMenu: 'Desktop Plugins…', pluginsMenuPackagedOnly: 'Desktop Plugins… (available in packaged applications)', checkUpdatesMenu: 'Check for Updates…', @@ -20,6 +28,13 @@ export const en = { pluginWindowTitle: 'DeepSeek Harness — Desktop Plugins', pluginManagerDescription: 'Plugins are installed only in the Desktop node_modules and are managed by the bundled pnpm.', refresh: 'Refresh', + enable: 'Enable', + disable: 'Disable', + disabled: 'Disabled', + retry: 'Retry startup', + disableAll: 'Disable all plugins and retry', + recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.', + changingActivation: 'Changing plugin activation…', npmPackage: 'npm package', install: 'Install', installed: 'Installed', @@ -42,6 +57,14 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', startupFailed: 'DeepSeek Harness 无法启动', + startupLoading: '正在启动 DeepSeek Harness…', + startupLoadingDescription: '准备就绪后将自动打开工作区。', + startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。', + startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。', + startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。', + restartApplication: '关闭并重启', + resetConfiguration: '重置 Desktop 并重试', + disableThirdPartyPlugins: '禁用全部第三方插件并重试', pluginsMenu: '桌面插件…', pluginsMenuPackagedOnly: '桌面插件…(打包应用中可用)', checkUpdatesMenu: '检查更新…', @@ -59,6 +82,13 @@ export const zh = { pluginWindowTitle: 'DeepSeek Harness — 桌面插件', pluginManagerDescription: '插件只安装到桌面端自己的 node_modules,并由内置 pnpm 管理。', refresh: '刷新', + enable: '启用', + disable: '禁用', + disabled: '已禁用', + retry: '重试启动', + disableAll: '禁用全部插件并重试', + recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。', + changingActivation: '正在更改插件启用状态…', npmPackage: 'npm 包', install: '安装', installed: '已安装', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8bc9fdcefd..a73a6223a5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -15,16 +15,31 @@ import { import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts' import { DesktopHostProcess } from './host-process.ts' +import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts' import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' +import { desktopErrorState } from './startup-error.ts' +import { startupFailureDocument } from './startup-document.ts' const SCHEME = 'dsh-app' let focusPrimaryWindow = (): void => {} +type RecoveryAction = 'restart' | 'plugins' | 'reset' +let profileRecoveryAvailable = (): boolean => false +const emergencyPages = new WeakMap() +let recoverApplication = (action: RecoveryAction): Promise => { + if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application')) + app.relaunch() + app.quit() + return Promise.resolve() +} -function errorOf(reason: unknown, fallback: string): Error { - return reason instanceof Error ? reason : new Error(fallback) +async function showEmergencyDocument(window: BrowserWindow, message: string): Promise { + const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable()) + const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}` + emergencyPages.set(window, { url, message, busy: false }) + await window.loadURL(url) } protocol.registerSchemesAsPrivileged([{ @@ -49,7 +64,7 @@ const MIME: Readonly> = { interface RuntimeResources { readonly node: string readonly pnpm: string - readonly seed: string + readonly dsh: string } function runtimeResources(): RuntimeResources { @@ -58,15 +73,8 @@ function runtimeResources(): RuntimeResources { ?? join(process.resourcesPath, 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node') const pnpm = (development ? process.env.DSH_DESKTOP_PNPM_ENTRY : undefined) ?? join(process.resourcesPath, 'runtime', 'pnpm', 'bin', 'pnpm.mjs') - const seed = (development ? process.env.DSH_DESKTOP_SEED_DIR : undefined) ?? join(process.resourcesPath, 'seed') - return { node, pnpm, seed } -} - -function developmentProject(): string | undefined { - const configured = process.env.DSH_DESKTOP_DEV_PROJECT_DIR - if (configured === undefined || configured === '') return undefined - if (app.isPackaged) throw new Error('dsh desktop: development project override is unavailable in packaged applications') - return resolve(configured) + const dsh = (development ? process.env.DSH_DESKTOP_DSH_DIR : undefined) ?? join(process.resourcesPath, 'dsh') + return { node, pnpm, dsh } } function developmentHostInspectPort(enabled: boolean): number | undefined { @@ -79,13 +87,13 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } -function createWindow(preload: string): BrowserWindow { +function createWindow(preload: string, show = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, height: 840, minWidth: 880, minHeight: 600, - show: false, + show, webPreferences: { preload, nodeIntegration: false, @@ -97,6 +105,15 @@ function createWindow(preload: string): BrowserWindow { window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) window.webContents.on('will-navigate', (event, url) => { if (new URL(url).protocol !== `${SCHEME}:`) event.preventDefault() + const page = emergencyPages.get(window) + if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return + const action = new URL(url) + if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return + if (action.hostname !== 'restart' && !profileRecoveryAvailable()) return + page.busy = true + void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => { + if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`) + }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false }) }) return window } @@ -133,12 +150,13 @@ async function serveShellAsset(request: Request): Promise { async function main(): Promise { const resources = runtimeResources() const paths = resolveDesktopPaths() - const development = developmentProject() + const development = app.isPackaged ? undefined : join(app.getAppPath(), '.desktop-build', 'development', 'project') const activeProject = development ?? paths.profile - const hostInspectPort = developmentHostInspectPort(development !== undefined) const manager = new DesktopProjectManager(paths, resources) - if (development === undefined) manager.recover() - let host: DesktopHostProcess | undefined + profileRecoveryAvailable = () => development === undefined && manager.canRecoverProfile() + let pageError: Extract | undefined + let quitting = false + let startup: Promise | undefined let mainWindow: BrowserWindow | undefined let pluginWindow: BrowserWindow | undefined let shellInstallerOwnsQuit = false @@ -147,6 +165,56 @@ async function main(): Promise { const messages = locale.messages const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url)) const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url)) + const startupUrl = `${SCHEME}://shell/startup.html` + const applicationUrl = `${SCHEME}://app/index.html` + let navigation: { window: BrowserWindow; url: string; promise: Promise } | undefined + let emergencyDocument = false + + const showEmergencyError = async (error: unknown): Promise => { + if (quitting || emergencyDocument) return + emergencyDocument = true + const diagnostic = desktopErrorState(error).message + pageError = { phase: 'error', message: diagnostic } + if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic) + } + + const navigateMain = (url: string): Promise => { + const window = mainWindow + if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve() + if (navigation?.window === window && navigation.url === url) return navigation.promise + const next = { window, url, promise: Promise.resolve() } + next.promise = window.loadURL(url).catch((error: unknown) => { + if (quitting || window.isDestroyed() || navigation !== next) return + navigation = undefined + throw error + }) + navigation = next + return next.promise + } + const backendState = (): DesktopBackendState => { + const state = pageError ?? backend.state + return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state + } + const publishBackend = (state: DesktopBackendState): void => { + for (const window of BrowserWindow.getAllWindows()) { + window.webContents.send(DESKTOP_IPC.backendState, state) + } + } + const backend = new DesktopBackendController((onFailure) => { + if (development === undefined) manager.assertProfileRuntime(activeProject) + const hostInspectPort = developmentHostInspectPort(development !== undefined) + const host = new DesktopHostProcess(resources.node, development ?? resources.dsh, activeProject, + hostInspectPort, process.env, onFailure) + return { + start: () => host.start(), + stop: () => host.stop(), + fetch: (request: Request) => host.fetch(request), + } + }, (state) => { + if (state.phase === 'starting' && !emergencyDocument) pageError = undefined + publishBackend(backendState()) + if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) }) + }) const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => { updateState = state @@ -156,76 +224,73 @@ async function main(): Promise { return state } - const startHost = async (projectDir = activeProject): Promise => { - const next = new DesktopHostProcess(resources.node, projectDir, hostInspectPort) - await next.start() - return next - } const hooks: DesktopProjectHooks = { - healthCheck: async (projectDir) => { - const active = host - host = undefined - await active?.stop() - let healthFailure: unknown - let probe: DesktopHostProcess | undefined - try { - probe = await startHost(projectDir) - await probe.stop() - } catch (error) { - healthFailure = error - await probe?.stop().catch(() => undefined) - } - let restartFailure: unknown - if (active !== undefined) { - try { - host = await startHost() - } catch (error) { - restartFailure = error - } - } - if (healthFailure !== undefined && restartFailure !== undefined) { - throw new AggregateError([ - errorOf(healthFailure, 'desktop project: staged health check failed'), - errorOf(restartFailure, 'desktop project: active backend restart failed'), - ], 'desktop project: staged health check and active backend restart failed') - } - if (healthFailure !== undefined) throw errorOf(healthFailure, 'desktop project: staged health check failed') - if (restartFailure !== undefined) throw errorOf(restartFailure, 'desktop project: active backend restart failed') - }, - beforeActivate: async () => { - const active = host - host = undefined - await active?.stop() - }, - afterActivate: async () => { - host = await startHost() - }, + beforeChange: () => backend.stop(), + afterChange: () => backend.start(async () => {}), } - if (development === undefined) { - await manager.applyRelease(resources.seed, app.getVersion(), { - ...hooks, - beforeActivate: async () => {}, - afterActivate: async () => {}, - }) + recoverApplication = async (action): Promise => { + await startup?.catch(() => undefined) + await backend.stop() + if (action === 'restart') { + app.relaunch() + app.quit() + return + } + if (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice) + if (action === 'reset') await manager.resetConfiguration(hooks) + else await manager.mutate({ type: 'plugins-disable-all' }, hooks) + emergencyDocument = false + pageError = undefined + navigation = undefined + await navigateMain(applicationUrl) + } + + const showStartupError = async (error: unknown): Promise => { + if (quitting) return + pageError = desktopErrorState(error) + try { await navigateMain(startupUrl) } + catch (navigationError) { + await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed)) + } + publishBackend(backendState()) + } + const reconcileBackend = (): Promise => { + startup ??= (async () => { + pageError = undefined + await navigateMain(startupUrl) + await backend.start(async () => { + if (development === undefined) { + await manager.applyRelease() + } + }) + if (backend.host !== undefined) await navigateMain(applicationUrl) + })().catch(async (error: unknown) => { + await showStartupError(error) + throw error + }).finally(() => { startup = undefined }) + return startup } - host = await startHost() const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { shellInstallerOwnsQuit = true - const active = host - host = undefined - await active?.stop() + await backend.stop() }, ) protocol.handle(SCHEME, (request) => { const url = new URL(request.url) - if (url.hostname === 'shell') return serveShellAsset(request) + if (url.hostname === 'shell') return serveShellAsset(request).then((response) => { + if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) { + void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`)) + .catch((error: unknown) => { console.error(error) }) + } + return response + }) if (url.hostname !== 'app') return Promise.resolve(new Response(null, { status: 404 })) - const active = host + const active = backend.host if (active === undefined) return Promise.resolve(new Response('backend unavailable', { status: 503 })) return active.fetch(request) }) @@ -235,8 +300,16 @@ async function main(): Promise { if (development !== undefined) { throw new Error('dsh desktop: plugin package changes require a packaged application') } - await manager.mutate(mutation, hooks) - if (mainWindow !== undefined && !mainWindow.isDestroyed()) mainWindow.webContents.reload() + await startup?.catch(() => undefined) + pageError = undefined + await navigateMain(startupUrl) + try { + await manager.mutate(mutation, hooks) + await navigateMain(applicationUrl) + } catch (error) { + await showStartupError(error) + throw error + } } ipcMain.handle(DESKTOP_IPC.localeGet, (event) => { assertDesktopSender(event, ['shell']) @@ -261,6 +334,42 @@ async function main(): Promise { } return mutate(event, { type: 'plugin-update', name, version }) }) + ipcMain.handle(DESKTOP_IPC.pluginsToggle, (event, name: unknown, enabled: unknown) => { + if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request') + return mutate(event, { type: 'plugin-toggle', name, enabled }) + }) + ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' })) + ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => { + assertDesktopSender(event, ['shell']) + return backendState() + }) + ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => { + assertDesktopSender(event, ['shell']) + await reconcileBackend() + focusPrimaryWindow() + }) + ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => { + assertDesktopSender(event, ['shell']) + try { + await recoverApplication('restart') + } catch (error) { + await showStartupError(error) + } + }) + ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => { + assertDesktopSender(event, ['shell']) + if (development !== undefined) throw new Error('Desktop configuration reset requires a packaged application') + const failure = backendState() + if (failure.phase !== 'error') { + throw new Error('Desktop profile reset requires a startup failure') + } + await startup?.catch(() => undefined) + try { + await recoverApplication('reset') + } catch (error) { + await showStartupError(error) + } + }) ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => { assertDesktopSender(event, ['shell']) return updates.check() @@ -341,17 +450,26 @@ async function main(): Promise { }])) const createMainWindow = (): BrowserWindow => { - const window = createWindow(appPreload) + const window = createWindow(appPreload, true) mainWindow = window - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) + window.webContents.on('preload-error', (_event, _path, error) => { + void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) }) + }) + window.webContents.on('render-process-gone', (_event, details) => { + navigation = undefined + emergencyDocument = false + void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`)) + .catch((failure: unknown) => { console.error(failure) }) + }) return window } focusPrimaryWindow = () => { const window = mainWindow if (window === undefined || window.isDestroyed()) { - const replacement = createMainWindow() - void replacement.loadURL(`${SCHEME}://app/index.html`) + createMainWindow() + void navigateMain(backendState().phase === 'ready' ? applicationUrl : startupUrl) + .catch((error: unknown) => { console.error(error) }) return } if (window.isMinimized()) window.restore() @@ -359,14 +477,6 @@ async function main(): Promise { window.focus() } - mainWindow = createMainWindow() - await mainWindow.loadURL(`${SCHEME}://app/index.html`) - if (development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { - mainWindow.webContents.openDevTools({ mode: 'detach' }) - } - publishUpdate(updateState) - setTimeout(() => { void checkAndPrompt(false) }, 10_000) - app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) focusPrimaryWindow() }) @@ -374,13 +484,23 @@ async function main(): Promise { if (process.platform !== 'darwin') app.quit() }) app.on('before-quit', (event) => { - if (shellInstallerOwnsQuit) return - if (host === undefined) return + if (shellInstallerOwnsQuit || quitting) return event.preventDefault() - const active = host - host = undefined - void active.stop().finally(() => { app.quit() }) + quitting = true + void backend.close().catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) }) + + mainWindow = createMainWindow() + await reconcileBackend().catch(() => undefined) + // Window lifecycle callbacks run while backend startup is pending. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (quitting) return + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (mainWindow !== undefined && development !== undefined && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + publishUpdate(updateState) + setTimeout(() => { void checkAndPrompt(false) }, 10_000) } const ownsDesktopInstance = claimDesktopSingleInstance(app, () => { focusPrimaryWindow() }) @@ -392,6 +512,10 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk if (diagnosticFile !== undefined) { await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined) } - dialog.showErrorBox(resolveDesktopLocale(app.getLocale()).messages.startupFailed, message) + const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true) + window.once('closed', () => { app.quit() }) + await showEmergencyDocument(window, message) +}).catch((error: unknown) => { + console.error(error) app.exit(1) }) diff --git a/apps/desktop/src/owned-directory.ts b/apps/desktop/src/owned-directory.ts new file mode 100644 index 0000000000..0caad07ca8 --- /dev/null +++ b/apps/desktop/src/owned-directory.ts @@ -0,0 +1,26 @@ +/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */ + +import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Remove an owned directory and its contents, unlinking root and nested links. + * Missing roots are ignored; existing roots must be directories or links. + * @param path - Owned directory or link to remove; link targets are preserved. + */ +export function removeOwnedDirectory(path: string): void { + const stat = lstatSync(path, { throwIfNoEntry: false }) + if (stat === undefined) return + if (stat.isSymbolicLink()) { + unlinkSync(path) + return + } + if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) + // Electron's recursive rm follows nested Windows junctions into installed resources. + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) removeOwnedDirectory(child) + else unlinkSync(child) + } + rmdirSync(path) +} diff --git a/apps/desktop/src/paths.ts b/apps/desktop/src/paths.ts index 4f883929db..5727e5c013 100644 --- a/apps/desktop/src/paths.ts +++ b/apps/desktop/src/paths.ts @@ -7,9 +7,6 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' export interface DesktopPaths { readonly root: string readonly profile: string - readonly staging: string - readonly rollback: string - readonly pending: string readonly lock: string readonly pnpm: { readonly root: string @@ -32,10 +29,7 @@ export function resolveDesktopPaths(dshHome: string = resolveDshHome()): Desktop return { root, profile: join(dshHome, 'profiles', 'desktop'), - staging: join(root, 'staging'), - rollback: join(root, 'rollback', 'profile'), - pending: join(root, 'pending.json'), - lock: join(root, 'lock'), + lock: join(dshHome, 'profiles', 'desktop', 'lock'), pnpm: { root: pnpm, store: join(pnpm, 'store'), diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index c6a839aba6..c05b749a05 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,24 @@ -/** Minimal marker that selects the desktop custom-protocol API carrier. */ +/** Startup controls for shell documents; application documents receive only the carrier marker. */ -import { contextBridge } from 'electron' +import { contextBridge, ipcRenderer } from 'electron' +import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts' +import type { DesktopBackendState } from './backend-controller.ts' -contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 }) +const startup: DshDesktopStartupApi = { + protocolVersion: 1, + locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType, + backend: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.backendState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } + }, + }, + disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, + restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise, + resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise, +} + +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell' + ? startup : { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2fc4dc8ee0..b9c04bd40a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts' +import type { DesktopBackendState } from './backend-controller.ts' const api: DshDesktopApi = { protocolVersion: 1, @@ -10,8 +11,19 @@ const api: DshDesktopApi = { list: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsList) as Promise extends Promise ? T : never>, add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise, remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise, + toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise, + disableAll: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise, update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise, }, + backend: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType, + retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.backendState, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) } + }, + }, updates: { check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise, install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise, diff --git a/apps/desktop/src/profile-packages.ts b/apps/desktop/src/profile-packages.ts new file mode 100644 index 0000000000..1cb907ffa7 --- /dev/null +++ b/apps/desktop/src/profile-packages.ts @@ -0,0 +1,238 @@ +/** Desktop-owned host links and validation of the external plugin dependency graph. */ + +import { createHash } from 'node:crypto' +import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, readdirSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { satisfies } from 'semver' +import { desktopRuntimeId, runtimePath, type DesktopRuntimeDescriptor } from './runtime-tree.ts' + +/** Applied runtime identity and the only links Desktop may replace. */ +export const DESKTOP_PROFILE_STATE = 'desktop-runtime-state.json' + +/** Durable ownership of a package-directory link. */ +export interface DesktopPackageLink { + readonly name: string + readonly target: string +} + +/** Runtime identity and managed links; package preparation may still be pending. */ +export interface DesktopProfileState { + readonly schemaVersion: 1 + readonly runtimeId: string + readonly version: string + readonly nodeVersion: string + readonly platform: string + readonly arch: string + readonly lockHash: string + readonly links: readonly DesktopPackageLink[] +} + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function stat(path: string): ReturnType | undefined { + try { return lstatSync(path) } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + return undefined + } +} + +function inside(root: string, path: string): boolean { + const child = relative(root, path) + return child === '' || (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`)) +} + +/** + * Read profile state without interpreting an unpublished predecessor format. + * @param profile - Desktop profile directory. + * @returns Validated state, or undefined for an uninitialized profile. + */ +export function readDesktopProfileState(profile: string): DesktopProfileState | undefined { + const path = join(profile, DESKTOP_PROFILE_STATE) + if (!existsSync(path)) return undefined + const value: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (!record(value) || value.schemaVersion !== 1 || typeof value.runtimeId !== 'string' + || !/^[a-f0-9]{64}$/u.test(value.runtimeId) || typeof value.version !== 'string' + || typeof value.nodeVersion !== 'string' || typeof value.platform !== 'string' || typeof value.arch !== 'string' + || typeof value.lockHash !== 'string' || !Array.isArray(value.links)) { + throw new Error('desktop profile: invalid runtime state') + } + const links = value.links.map((link: unknown): DesktopPackageLink => { + if (!record(link) || typeof link.name !== 'string' || !PACKAGE_NAME.test(link.name) + || typeof link.target !== 'string' || !isAbsolute(link.target)) { + throw new Error('desktop profile: invalid managed link') + } + return { name: link.name, target: link.target } + }) + if (new Set(links.map(link => link.name)).size !== links.length) throw new Error('desktop profile: duplicate managed link') + return { schemaVersion: 1, runtimeId: value.runtimeId, version: value.version, nodeVersion: value.nodeVersion, + platform: value.platform, arch: value.arch, lockHash: value.lockHash, links } +} + +/** + * Hash the plugin lockfile, including the empty-profile case. + * @param profile - Desktop profile directory. + * @returns Lockfile content identity. + */ +export function desktopPluginLockHash(profile: string): string { + const lock = join(profile, 'pnpm-lock.yaml') + return createHash('sha256').update(existsSync(lock) ? readFileSync(lock) : '').digest('hex') +} + +/** + * Remove only recorded host links, without following even broken targets. + * @param profile - Desktop profile. + */ +export function unlinkDesktopHostPackages(profile: string): void { + for (const link of readDesktopProfileState(profile)?.links ?? []) { + const path = join(profile, 'node_modules', link.name) + const entry = stat(path) + if (entry === undefined) continue + if (!entry.isSymbolicLink() || resolve(dirname(path), readlinkSync(path)) !== resolve(link.target)) { + throw new Error(`desktop profile: refusing to replace unowned package ${link.name}`) + } + unlinkSync(path) + } +} + +/** + * Bind an external profile to this application's real package directories. + * @param profile - Candidate profile. + * @param root - Current immutable runtime directory. + * @param runtime - Verified release descriptor. + */ +export function linkDesktopHostPackages(profile: string, root: string, runtime: DesktopRuntimeDescriptor): void { + unlinkDesktopHostPackages(profile) + const links = runtime.sharedPackages.map(entry => ({ name: entry.name, target: runtimePath(root, entry.path) })) + for (const link of links) { + const path = join(profile, 'node_modules', link.name) + if (stat(path) !== undefined) throw new Error(`desktop profile: plugin installed reserved host package ${link.name}`) + mkdirSync(dirname(path), { recursive: true }) + symlinkSync(link.target, path, process.platform === 'win32' ? 'junction' : 'dir') + } + const state: DesktopProfileState = { schemaVersion: 1, runtimeId: desktopRuntimeId(runtime), version: runtime.release.version, + nodeVersion: runtime.release.nodeVersion, platform: runtime.platform, arch: runtime.arch, + lockHash: desktopPluginLockHash(profile), links } + writeFileSync(join(profile, DESKTOP_PROFILE_STATE), `${JSON.stringify(state, undefined, 2)}\n`, { mode: 0o600 }) +} + +interface PackageManifest { + readonly name: string + readonly version: string + readonly dependencies: Readonly> + readonly optionalDependencies: Readonly> + readonly peerDependencies: Readonly> + readonly optionalPeers: ReadonlySet +} + +function manifest(path: string): PackageManifest { + const value: unknown = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')) + if (!record(value) || typeof value.name !== 'string' || typeof value.version !== 'string') { + throw new Error(`desktop profile: invalid package manifest ${path}`) + } + const dependencies = (key: string): Record => { + const field = value[key] + if (field === undefined) return {} + if (!record(field) || Object.entries(field).some(([name, spec]) => !PACKAGE_NAME.test(name) || typeof spec !== 'string')) { + throw new Error(`desktop profile: invalid ${key} in ${path}`) + } + return field as Record + } + const optionalPeers = new Set() + if (record(value.peerDependenciesMeta)) { + for (const [name, meta] of Object.entries(value.peerDependenciesMeta)) { + if (record(meta) && meta.optional === true) optionalPeers.add(name) + } + } + return { name: value.name, version: value.version, dependencies: dependencies('dependencies'), + optionalDependencies: dependencies('optionalDependencies'), peerDependencies: dependencies('peerDependencies'), optionalPeers } +} + +function packageFrom(anchor: string, name: string): string | undefined { + if (!PACKAGE_NAME.test(name)) throw new Error(`desktop profile: invalid package name ${name}`) + for (const modules of createRequire(join(anchor, 'package.json')).resolve.paths(name) ?? []) { + const path = join(modules, name) + if (existsSync(join(path, 'package.json'))) return realpathSync.native(path) + } + return undefined +} + +/** + * Prove active plugin dependencies stay local and share the host's exact package instances. + * @param profile - Profile with its generated host links present. + * @param root - Immutable runtime directory. + * @param runtime - Verified shared package inventory. + * @param activePlugins - Explicit enabled plugin roots whose peer compatibility is required. + */ +export function validateDesktopPluginGraph( + profile: string, root: string, runtime: DesktopRuntimeDescriptor, activePlugins: readonly string[], +): void { + const profileRoot = realpathSync.native(profile) + const shared = new Map(runtime.sharedPackages.map((entry) => { + return [entry.name, realpathSync.native(runtimePath(root, entry.path))] as const + })) + for (const [name, path] of shared) { + if (packageFrom(profile, name) !== path) throw new Error(`desktop profile: missing or incorrect host link ${name}`) + } + if (activePlugins.length === 0) return + const scanned = new Set() + const scan = (modules: string): void => { + if (!existsSync(modules)) return + if (lstatSync(modules).isSymbolicLink()) throw new Error(`desktop profile: linked package container ${modules}`) + const directory = realpathSync.native(modules) + if (scanned.has(directory)) return + scanned.add(directory) + for (const entry of readdirSync(modules, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue + const path = join(modules, entry.name) + if (entry.name.startsWith('@')) { scan(path); continue } + if (!existsSync(join(path, 'package.json'))) throw new Error(`desktop profile: invalid installed package ${path}`) + const canonical = realpathSync.native(path) + const info = manifest(canonical) + const host = shared.get(info.name) + if (host !== undefined) { + if (canonical !== host || path !== join(profile, 'node_modules', info.name)) { + throw new Error(`desktop profile: duplicate or aliased host package ${info.name} at ${path}`) + } + continue + } + if (entry.isSymbolicLink()) throw new Error(`desktop profile: linked private package ${path}`) + if (!inside(profileRoot, canonical)) throw new Error(`desktop profile: package resolves outside profile: ${path}`) + scan(join(path, 'node_modules')) + } + } + scan(join(profile, 'node_modules')) + const visited = new Set() + const visit = (path: string, chain: string): void => { + if (visited.has(path)) return + visited.add(path) + const info = manifest(path) + const deps = { ...info.dependencies, ...info.optionalDependencies } + for (const [name, range] of Object.entries({ ...deps, ...info.peerDependencies })) { + const peer = name in info.peerDependencies + const optional = peer ? info.optionalPeers.has(name) : name in info.optionalDependencies + const target = packageFrom(path, name) + if (target === undefined && optional) continue + if (target === undefined) throw new Error(`desktop profile: ${chain} requires missing ${name}@${range}`) + const host = shared.get(name) + if (host !== undefined && name in deps) throw new Error(`desktop profile: ${chain} must declare ${name} as a peer dependency`) + if (host !== undefined ? target !== host : !inside(profileRoot, target)) { + throw new Error(`desktop profile: ${chain} resolves ${name} outside its owned packages`) + } + const dependency = manifest(target) + if (peer && !satisfies(dependency.version, range)) { + throw new Error(`desktop profile: ${chain} requires ${name}@${range}, found ${dependency.version}`) + } + if (host === undefined) visit(target, `${chain} -> ${name}`) + } + } + for (const name of activePlugins) { + const path = packageFrom(profile, name) + if (path === undefined || !inside(profileRoot, path)) throw new Error(`desktop profile: missing local plugin ${name}`) + visit(path, name) + } +} diff --git a/apps/desktop/src/project-manager.ts b/apps/desktop/src/project-manager.ts index b9b94f7ac8..a91c6af6b2 100644 --- a/apps/desktop/src/project-manager.ts +++ b/apps/desktop/src/project-manager.ts @@ -1,11 +1,8 @@ -/** Transactional owner of the reserved desktop profile and its private pnpm state. */ +/** In-place owner of the reserved desktop profile and its private pnpm state. */ +import { valid } from 'semver' import { spawn } from 'node:child_process' -import { createHash, randomUUID } from 'node:crypto' import { - constants, - copyFileSync, - cpSync, existsSync, fsyncSync, ftruncateSync, @@ -13,41 +10,33 @@ import { mkdirSync, openSync, closeSync, - readdirSync, readFileSync, - renameSync, - rmSync, + readdirSync, + realpathSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs' -import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { delimiter, dirname, join, resolve, sep } from 'node:path' import { - DESKTOP_PACKAGES_DIR, - DESKTOP_PACKAGE_SET_FILE, DESKTOP_HOST_PACKAGE, desktopCorePackageOverrides, - desktopDshPackageSpec, - readDesktopCorePackageSet, verifyDesktopCorePackageSet, } from './core-package-set.ts' import type { DesktopPaths } from './paths.ts' -import { parseDesktopRelease, type DesktopRelease } from './release.ts' -import { extractPnpmStoreArchives, mergePnpmStore } from './seed-store.ts' - -/** Files the package transaction copies between active and staging projects. */ -const DESKTOP_PROJECT_FILES = [ - 'package.json', - 'pnpm-lock.yaml', - 'pnpm-workspace.yaml', - 'desktop-release.json', - DESKTOP_PACKAGE_SET_FILE, -] as const +import { removeOwnedDirectory } from './owned-directory.ts' +import type { DesktopRelease } from './release.ts' +import { desktopRuntimeId, readDesktopRuntime, type DesktopRuntimeDescriptor } from './runtime-tree.ts' +import { + desktopPluginLockHash, linkDesktopHostPackages, readDesktopProfileState, + unlinkDesktopHostPackages, validateDesktopPluginGraph, type DesktopProfileState, +} from './profile-packages.ts' /** Desktop plugin record derived from the installed profile. */ export interface DesktopPluginRecord { readonly name: string readonly version: string + readonly enabled: boolean } /** Installed desktop project manifest slice. */ @@ -63,28 +52,19 @@ interface DesktopProjectManifest { } } -/** Journaled activation step used for crash recovery. */ -interface DesktopPendingTransaction { - readonly schemaVersion: 1 - readonly id: string - readonly stagingProfile: string - readonly step: 'prepared' | 'active-moved' | 'staging-activated' -} - /** Exact executables the desktop shell bundles. */ export interface DesktopRuntimeExecutables { readonly node: string readonly pnpm: string + readonly dsh: string } -/** Hooks that bind project replacement to backend lifecycle and health. */ +/** Hooks that stop the backend before profile writes and restart it after success. */ export interface DesktopProjectHooks { - /** Prove the staged dependency graph while the active backend is stopped. */ - healthCheck(projectDir: string): Promise - /** Stop the active backend and await process exit before directory moves. */ - beforeActivate(): Promise - /** Start the selected active project after commit or rollback. */ - afterActivate(): Promise + /** Stop the active backend and await process exit before modifying its files. */ + beforeChange(): Promise + /** Start the modified profile after package preparation succeeds. */ + afterChange(): Promise } /** Supported dependency mutation. */ @@ -92,12 +72,8 @@ export type DesktopProjectMutation = | { readonly type: 'plugin-add'; readonly spec: string } | { readonly type: 'plugin-remove'; readonly name: string } | { readonly type: 'plugin-update'; readonly name: string; readonly version: string } - -interface DesktopSeedIntegrityRecord { - readonly path: string - readonly bytes: number - readonly sha256: string -} + | { readonly type: 'plugin-toggle'; readonly name: string; readonly enabled: boolean } + | { readonly type: 'plugins-disable-all' } const PROJECT_NAME = '@deepseek-ai/dsh-desktop-runtime' const DSH_PACKAGE = '@deepseek-ai/dsh' @@ -133,19 +109,10 @@ function workspaceFile(overrides: Readonly> = {}): string return `packages:\n - .\n\n${overrideSection}${WORKSPACE_SETTINGS}allowBuilds:\n node-pty: true\n koffi: true\n fs-ext: true\n ${JSON.stringify(coreBuildKey)}: true\n '@google/genai': false\n protobufjs: false\n node-addon-require-builtin: false\n` } -function releaseFile(projectDir: string): DesktopRelease { - return parseDesktopRelease(readJson(join(projectDir, 'desktop-release.json'))) -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function isDescendant(root: string, target: string): boolean { - const child = relative(root, target) - return child !== '' && child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child) -} - function assertPackageName(name: string): void { if (!PACKAGE_NAME_PATTERN.test(name)) throw new Error(`desktop project: invalid npm package name ${JSON.stringify(name)}`) } @@ -155,11 +122,11 @@ function assertVersion(version: string): void { } /** - * Validate one registry package spec and return its requested package name when explicit. + * Validate one registry package spec and return its package name. * @param spec - npm registry name with an optional version or tag. - * @returns package name, or undefined when the spec's final name is registry-resolved. + * @returns Requested package name. */ -export function packageNameFromSpec(spec: string): string | undefined { +export function packageNameFromSpec(spec: string): string { if (spec === '' || spec.startsWith('-') || /[\s\\]/u.test(spec) || spec.includes('://') || spec.startsWith('file:')) { throw new Error(`desktop project: unsupported npm package spec ${JSON.stringify(spec)}`) } @@ -179,94 +146,20 @@ export function packageNameFromSpec(spec: string): string | undefined { return name } -function removeOwnedDirectory(path: string): void { - if (!existsSync(path)) return - const stat = lstatSync(path) - if (stat.isSymbolicLink()) { - unlinkSync(path) - return - } - if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`) - rmSync(path, { recursive: true }) -} - -function copyMetadata(source: string, target: string): void { - mkdirSync(target, { recursive: true, mode: 0o700 }) - for (const filename of DESKTOP_PROJECT_FILES) { - const from = join(source, filename) - if (existsSync(from)) copyFileSync(from, join(target, filename), constants.COPYFILE_EXCL) - } - cpSync(join(source, DESKTOP_PACKAGES_DIR), join(target, DESKTOP_PACKAGES_DIR), { - recursive: true, - force: false, - errorOnExist: true, - }) -} - -function seedFiles(root: string): readonly DesktopSeedIntegrityRecord[] { - const files: DesktopSeedIntegrityRecord[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - const relativePath = path.slice(root.length + 1).split(sep).join('/') - if (relativePath === 'integrity.json') continue - if (entry.isSymbolicLink()) throw new Error(`desktop seed: symbolic link is not allowed: ${relativePath}`) - if (entry.isDirectory()) { - visit(path) - continue - } - if (!entry.isFile()) throw new Error(`desktop seed: unsupported file type: ${relativePath}`) - const body = readFileSync(path) - files.push({ - path: relativePath, - bytes: body.byteLength, - sha256: createHash('sha256').update(body).digest('hex'), - }) - } - } - visit(root) - return files.sort((left, right) => left.path.localeCompare(right.path)) -} - -/** Verify the packaged offline seed before any content enters writable desktop state. */ -export function verifySeedIntegrity(seedDir: string): void { - const integrityPath = join(seedDir, 'integrity.json') - const integrity = readJson(integrityPath) - if (!isRecord(integrity) || integrity.schemaVersion !== 2 || !Array.isArray(integrity.files)) { - throw new Error(`desktop seed: invalid integrity inventory ${integrityPath}`) - } - const expected: DesktopSeedIntegrityRecord[] = integrity.files.map((record) => { - if (!isRecord(record) || typeof record.path !== 'string' || record.path === '' || record.path.startsWith('/') - || record.path.split('/').includes('..') || typeof record.bytes !== 'number' - || !Number.isSafeInteger(record.bytes) || record.bytes < 0 - || typeof record.sha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(record.sha256)) { - throw new Error(`desktop seed: invalid integrity record in ${integrityPath}`) - } - return { path: record.path, bytes: record.bytes, sha256: record.sha256 } - }).sort((left, right) => left.path.localeCompare(right.path)) - const actual = seedFiles(seedDir) - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error('desktop seed: integrity verification failed') - } -} - function projectManifest(projectDir: string): DesktopProjectManifest { const path = join(projectDir, 'package.json') const value = readJson(path) const dsh = isRecord(value) && isRecord(value.dsh) ? value.dsh : undefined const profile = isRecord(dsh?.profile) ? dsh.profile : undefined if (!isRecord(value) || value.name !== PROJECT_NAME || value.private !== true - || typeof value.version !== 'string' || !isRecord(value.dependencies) + || typeof value.version !== 'string' || (value.dependencies !== undefined && !isRecord(value.dependencies)) || !Array.isArray(profile?.bundles) || !profile.bundles.every(bundle => typeof bundle === 'string')) { throw new Error(`desktop project: invalid desktop profile manifest ${path}`) } - const manifest = value as unknown as DesktopProjectManifest - const packageSet = readDesktopCorePackageSet(projectDir, releaseFile(projectDir).version) - const expectedOverrides = desktopCorePackageOverrides(packageSet) - if (manifest.dependencies[DSH_PACKAGE] !== desktopDshPackageSpec(packageSet) - || Object.entries(expectedOverrides).some(([name, spec]) => manifest.dependencies[name] !== spec) - || readFileSync(join(projectDir, 'pnpm-workspace.yaml'), 'utf8') !== workspaceFile(expectedOverrides)) { - throw new Error(`desktop project: core package mapping does not match ${DESKTOP_PACKAGE_SET_FILE}`) + const manifest = { ...value, dependencies: value.dependencies ?? {} } as unknown as DesktopProjectManifest + if (Object.entries(manifest.dependencies).some(([name, version]) => !PACKAGE_NAME_PATTERN.test(name) + || typeof version !== 'string' || valid(version) !== version)) { + throw new Error('desktop project: plugin dependencies must use exact registry versions') } return manifest } @@ -285,7 +178,7 @@ function profilePluginNames(projectDir: string): readonly string[] { } function pluginRecords(projectDir: string): readonly DesktopPluginRecord[] { - return profilePluginNames(projectDir).map(name => inspectPlugin(projectDir, name)) + return Object.keys(projectManifest(projectDir).dependencies).sort().map(name => inspectPlugin(projectDir, name)) } function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPluginRecord[]): void { @@ -296,7 +189,7 @@ function writeProfilePlugins(projectDir: string, plugins: readonly DesktopPlugin ...manifest.dsh, profile: { ...manifest.dsh.profile, - bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.map(plugin => plugin.name)], + bundles: [...DESKTOP_PROFILE_BUNDLES, ...plugins.filter(plugin => plugin.enabled).map(plugin => plugin.name)], }, }, } satisfies DesktopProjectManifest) @@ -322,12 +215,13 @@ function inspectPlugin(projectDir: string, requestedName: string): DesktopPlugin if ((patchPath !== packageDir && !patchPath.startsWith(packageDir + sep)) || !existsSync(patchPath)) { throw new Error(`desktop project: ${requestedName}@${manifest.version} declares an invalid bundle patch`) } - return { name: requestedName, version: manifest.version } + return { name: requestedName, version: manifest.version, enabled: profilePluginNames(projectDir).includes(requestedName) } } -/** Transactional desktop npm project manager. */ +/** Desktop npm project manager with direct writes and no rollback. */ export class DesktopProjectManager { private lockDescriptor: number | undefined + private descriptor: DesktopRuntimeDescriptor | undefined /** * @param paths - Electron-owned package state and reserved desktop profile paths. @@ -338,136 +232,156 @@ export class DesktopProjectManager { readonly runtime: DesktopRuntimeExecutables, ) {} - /** Recover an interrupted directory replacement before reading the active project. */ - recover(): void { - if (!existsSync(this.paths.pending)) return - const value = readJson(this.paths.pending) - if (!isRecord(value) || value.schemaVersion !== 1 - || typeof value.id !== 'string' || typeof value.stagingProfile !== 'string' - || !isDescendant(this.paths.staging, value.stagingProfile) - || (value.step !== 'prepared' && value.step !== 'active-moved' && value.step !== 'staging-activated')) { - throw new Error(`desktop project: invalid activation journal ${this.paths.pending}`) - } - const pending: DesktopPendingTransaction = { - schemaVersion: 1, - id: value.id, - stagingProfile: value.stagingProfile, - step: value.step, - } - if (!existsSync(this.paths.profile) && existsSync(this.paths.rollback)) { - mkdirSync(dirname(this.paths.profile), { recursive: true }) - renameSync(this.paths.rollback, this.paths.profile) - } - removeOwnedDirectory(pending.stagingProfile) - unlinkSync(this.paths.pending) - } - /** Read the active desktop plugin inventory. */ listPlugins(): readonly DesktopPluginRecord[] { if (!existsSync(this.paths.profile)) return [] return pluginRecords(this.paths.profile) } - /** Read the exact dsh version installed in the active desktop project. */ - dshVersion(): string { - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - return this.installedPackageVersion(DSH_PACKAGE) - } - - private installedPackageVersion(packageName: string): string { - const manifestPath = join(this.paths.profile, 'node_modules', ...packageName.split('/'), 'package.json') - const manifest = readJson(manifestPath) - if (!isRecord(manifest) || typeof manifest.version !== 'string') { - throw new Error(`desktop project: installed ${packageName} package has no version`) - } - assertVersion(manifest.version) - return manifest.version - } - - /** Read the release version applied to the active desktop project. */ - releaseVersion(): string { - if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - return releaseFile(this.paths.profile).version - } - - /** Install or reconcile the active project to the Electron package's exact release. */ - async applyRelease(seedDir: string, electronVersion: string, hooks: DesktopProjectHooks): Promise { - return this.withLock(async () => { - this.recover() - verifySeedIntegrity(seedDir) - const target = releaseFile(seedDir) - verifyDesktopCorePackageSet(seedDir, target.version) - if (target.version !== electronVersion) { - throw new Error(`desktop project: seed ${target.version} does not match Electron ${electronVersion}`) + /** + * Reinitialize the profile, deleting configuration and third-party packages without a backup. + * @param hooks - Stop the Host before resetting files; restart after preparation succeeds. + * @returns Completion of reset; the held lock and shared product data are preserved. + */ + async resetConfiguration(hooks: DesktopProjectHooks): Promise { + await this.withLock(async () => { + await hooks.beforeChange() + this.descriptor = this.readRuntime() + for (const entry of readdirSync(this.paths.profile, { withFileTypes: true })) { + const path = join(this.paths.profile, entry.name) + if (path === this.paths.lock) continue + if (entry.isDirectory()) removeOwnedDirectory(path) + else unlinkSync(path) } - if (existsSync(this.paths.profile) && this.releaseVersion() === target.version - && this.dshVersion() === target.version - && this.installedPackageVersion(DESKTOP_HOST_PACKAGE) === target.version) { - verifyDesktopCorePackageSet(this.paths.profile, target.version) + createPluginProfile(this.paths.profile) + this.prepareProfile(this.paths.profile) + await hooks.afterChange() + }) + } + + /** Read the dsh version supplied by this application's verified resources. */ + dshVersion(): string { + return this.currentRuntime().release.version + } + + /** Read the release most recently applied to the active profile. */ + releaseVersion(): string { + const state = readDesktopProfileState(this.paths.profile) + if (state === undefined) throw new Error('desktop project: active profile has no runtime state') + return state.version + } + + /** Reject a profile whose dependency links were prepared for another runtime. */ + assertProfileRuntime(projectDir: string): void { + if (existsSync(this.pendingPackages)) throw new Error('desktop project: package preparation is incomplete; retry startup') + if (readDesktopProfileState(projectDir)?.runtimeId !== desktopRuntimeId(this.currentRuntime())) { + throw new Error('desktop project: profile does not match this application runtime') + } + } + + /** @returns Whether application resources support profile recovery. */ + canRecoverProfile(): boolean { + return this.descriptor !== undefined && existsSync(this.runtime.node) && existsSync(this.runtime.dsh) + } + + private get pendingPackages(): string { return join(this.paths.profile, 'desktop-packages-pending') } + + private currentRuntime(): DesktopRuntimeDescriptor { + if (this.descriptor === undefined) throw new Error('desktop project: runtime metadata has not been loaded') + return this.descriptor + } + + private readRuntime(): DesktopRuntimeDescriptor { + this.descriptor = undefined + return readDesktopRuntime(this.runtime.dsh) + } + + private prepareProfile(projectDir: string): void { + const runtime = this.currentRuntime() + linkDesktopHostPackages(projectDir, this.runtime.dsh, runtime) + validateDesktopPluginGraph(projectDir, this.runtime.dsh, runtime, profilePluginNames(projectDir)) + } + + /** Read release metadata and reconcile its external profile without installing core packages. */ + async applyRelease(): Promise { + return this.withLock(async () => { + const target = this.readRuntime() + this.descriptor = target + const previous = readDesktopProfileState(this.paths.profile) + if (!existsSync(this.pendingPackages) && previous?.runtimeId === desktopRuntimeId(target) + && previous.lockHash === desktopPluginLockHash(this.paths.profile) + && previous.links.length === target.sharedPackages.length + && previous.links.every(link => existsSync(link.target) + && existsSync(join(this.paths.profile, 'node_modules', link.name)) + && realpathSync.native(link.target) === realpathSync.native(join(this.runtime.dsh, 'node_modules', link.name)))) { return false } - this.mergeSeedPnpmState(seedDir) - const stagingProfile = this.newStagingProfile() - try { - if (existsSync(this.paths.profile)) { - const plugins = pluginRecords(this.paths.profile) - copyMetadata(seedDir, stagingProfile) - await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - if (plugins.length > 0) { - await this.runPnpm(stagingProfile, [ - 'add', - ...plugins.map(plugin => `${plugin.name}@${plugin.version}`), - '--save-exact', - '--offline', - ]) - writeProfilePlugins(stagingProfile, plugins) - } - } else { - copyMetadata(seedDir, stagingProfile) - await this.runPnpm(stagingProfile, ['install', '--offline', '--frozen-lockfile', '--trust-lockfile']) - } - await hooks.healthCheck(stagingProfile) - await this.activate(stagingProfile, hooks) - return true - } catch (error) { - removeOwnedDirectory(stagingProfile) - throw error - } + if (previous === undefined) createPluginProfile(this.paths.profile) + await this.reconcileProfile(this.paths.profile, previous) + return true }) } - /** Apply one exact dependency mutation through a staging project. */ + /** Modify the current profile while its backend is stopped; failures retain partial changes. */ async mutate(mutation: DesktopProjectMutation, hooks: DesktopProjectHooks): Promise { await this.withLock(async () => { - this.recover() + this.currentRuntime() if (!existsSync(this.paths.profile)) throw new Error('desktop project: active profile is not installed') - verifyDesktopCorePackageSet(this.paths.profile, this.releaseVersion()) - const stagingProfile = this.newStagingProfile() - try { - copyMetadata(this.paths.profile, stagingProfile) - await this.applyMutation(stagingProfile, mutation) - await hooks.healthCheck(stagingProfile) - await this.activate(stagingProfile, hooks) - } catch (error) { - removeOwnedDirectory(stagingProfile) - throw error + await hooks.beforeChange() + if (mutation.type === 'plugins-disable-all') { + const manifest = projectManifest(this.paths.profile) + writeJson(join(this.paths.profile, 'package.json'), { + ...manifest, + dsh: { ...manifest.dsh, profile: { ...manifest.dsh.profile, bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + }) + this.prepareProfile(this.paths.profile) + await hooks.afterChange() + return } + const previous = readDesktopProfileState(this.paths.profile) + const packagesChanged = mutation.type !== 'plugin-toggle' + if (packagesChanged) unlinkDesktopHostPackages(this.paths.profile) + try { + await this.applyMutation(this.paths.profile, mutation) + } finally { + if (packagesChanged) linkDesktopHostPackages(this.paths.profile, this.runtime.dsh, this.currentRuntime()) + } + await this.reconcileProfile(this.paths.profile, previous, packagesChanged) + await hooks.afterChange() }) } - private newStagingProfile(): string { - const path = join(this.paths.staging, randomUUID(), 'profile') - mkdirSync(path, { recursive: true, mode: 0o700 }) - return path + private async reconcileProfile(projectDir: string, previous: DesktopProfileState | undefined, packagesChanged = false): Promise { + const target = this.currentRuntime() + const rebuild = (!packagesChanged && existsSync(this.pendingPackages)) + || (previous !== undefined && pluginRecords(projectDir).length > 0 + && (previous.nodeVersion !== target.release.nodeVersion || previous.platform !== target.platform || previous.arch !== target.arch)) + if (rebuild) { + writeFileSync(this.pendingPackages, '') + unlinkDesktopHostPackages(projectDir) + removeOwnedDirectory(join(projectDir, 'node_modules')) + await this.runPnpm(projectDir, ['install', '--frozen-lockfile', '--ignore-scripts']) + } + if (rebuild || packagesChanged) await this.finishPackageOperation(projectDir) + else this.prepareProfile(projectDir) } - private async applyMutation(projectDir: string, mutation: DesktopProjectMutation): Promise { + private async finishPackageOperation(projectDir: string): Promise { + this.prepareProfile(projectDir) + await this.runPnpm(projectDir, ['rebuild', '--pending']) + this.prepareProfile(projectDir) + unlinkSync(this.pendingPackages) + } + + private async applyMutation(projectDir: string, mutation: Exclude): Promise { switch (mutation.type) { case 'plugin-add': { const requestedName = packageNameFromSpec(mutation.spec) - if (requestedName === undefined) throw new Error('desktop project: plugin package name is required') - await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact']) - const installed = inspectPlugin(projectDir, requestedName) + if (this.currentRuntime().sharedPackages.some(entry => entry.name === requestedName)) { + throw new Error(`desktop project: cannot install host-owned package ${requestedName}`) + } + await this.runPnpm(projectDir, ['add', mutation.spec, '--save-exact', '--ignore-scripts']) + const installed = { ...inspectPlugin(projectDir, requestedName), enabled: true } const current = pluginRecords(projectDir).filter(plugin => plugin.name !== installed.name) writeProfilePlugins( projectDir, @@ -477,21 +391,21 @@ export class DesktopProjectManager { } case 'plugin-remove': { assertPackageName(mutation.name) - if (!profilePluginNames(projectDir).includes(mutation.name)) { + if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) { throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) } const remaining = pluginRecords(projectDir).filter(plugin => plugin.name !== mutation.name) - await this.runPnpm(projectDir, ['remove', mutation.name]) + await this.runPnpm(projectDir, ['remove', mutation.name, '--config.ignore-scripts=true']) writeProfilePlugins(projectDir, remaining) return } case 'plugin-update': assertPackageName(mutation.name) assertVersion(mutation.version) - if (!profilePluginNames(projectDir).includes(mutation.name)) { + if (!Object.hasOwn(projectManifest(projectDir).dependencies, mutation.name)) { throw new Error(`desktop project: plugin ${JSON.stringify(mutation.name)} is not installed`) } - await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact']) + await this.runPnpm(projectDir, ['add', `${mutation.name}@${mutation.version}`, '--save-exact', '--ignore-scripts']) { const installed = inspectPlugin(projectDir, mutation.name) writeProfilePlugins( @@ -500,54 +414,20 @@ export class DesktopProjectManager { ) } return + case 'plugin-toggle': { + assertPackageName(mutation.name) + const plugins = pluginRecords(projectDir) + if (!plugins.some(plugin => plugin.name === mutation.name)) throw new Error(`desktop project: plugin ${mutation.name} is not installed`) + writeProfilePlugins(projectDir, plugins.map(plugin => ( + plugin.name === mutation.name ? { ...plugin, enabled: mutation.enabled } : plugin + ))) + return + } default: mutation satisfies never } } - private mergeSeedPnpmState(seedDir: string): void { - const transactionRoot = join(this.paths.staging, randomUUID()) - const extractedStore = join(transactionRoot, 'store') - try { - extractPnpmStoreArchives(seedDir, extractedStore) - mergePnpmStore(extractedStore, this.paths.pnpm.store) - } finally { - removeOwnedDirectory(transactionRoot) - } - } - - private async activate(stagingProfile: string, hooks: DesktopProjectHooks): Promise { - const pending: DesktopPendingTransaction = { - schemaVersion: 1, - id: basename(dirname(stagingProfile)), - stagingProfile, - step: 'prepared', - } - writeJson(this.paths.pending, pending) - await hooks.beforeActivate() - let activeMoved = false - try { - removeOwnedDirectory(this.paths.rollback) - mkdirSync(dirname(this.paths.rollback), { recursive: true, mode: 0o700 }) - writeJson(this.paths.pending, { ...pending, step: 'active-moved' } satisfies DesktopPendingTransaction) - if (existsSync(this.paths.profile)) { - renameSync(this.paths.profile, this.paths.rollback) - activeMoved = true - } - mkdirSync(dirname(this.paths.profile), { recursive: true, mode: 0o700 }) - writeJson(this.paths.pending, { ...pending, step: 'staging-activated' } satisfies DesktopPendingTransaction) - renameSync(stagingProfile, this.paths.profile) - await hooks.afterActivate() - unlinkSync(this.paths.pending) - } catch (error) { - if (existsSync(this.paths.profile)) removeOwnedDirectory(this.paths.profile) - if (activeMoved && existsSync(this.paths.rollback)) renameSync(this.paths.rollback, this.paths.profile) - if (existsSync(this.paths.pending)) unlinkSync(this.paths.pending) - await hooks.afterActivate().catch(() => undefined) - throw error - } - } - private async runPnpm(projectDir: string, args: readonly string[]): Promise { const [command, ...commandArgs] = args if (command === undefined) throw new Error('desktop project: pnpm command is required') @@ -558,8 +438,9 @@ export class DesktopProjectManager { const npmrc = join(this.paths.pnpm.config, 'npmrc') if (!existsSync(npmrc)) writeFileSync(npmrc, '', { mode: 0o600 }) const inherited = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( - !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) + name !== 'NODE_OPTIONS' && name !== 'NODE_PATH' && !/^DSH_DESKTOP_/u.test(name) && !/^(?:npm|pnpm|corepack)_/iu.test(name) ))) + writeFileSync(this.pendingPackages, '') await new Promise((settle, reject) => { const child = spawn(this.runtime.node, [ this.runtime.pnpm, @@ -585,19 +466,7 @@ export class DesktopProjectManager { }, stdio: ['ignore', 'pipe', 'pipe'], }) - const childPid = child.pid - if (childPid === undefined) { - child.kill('SIGKILL') - reject(new Error('desktop project: pnpm did not report a process id')) - return - } - try { - this.writeLockOwner(childPid) - } catch (error) { - child.kill('SIGKILL') - reject(errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm')) - return - } + let failure: Error | undefined let diagnostics = '' let completed = false const appendDiagnostics = (chunk: string): void => { @@ -618,9 +487,10 @@ export class DesktopProjectManager { } settleChild() } - child.once('error', (error) => { complete(() => { reject(error) }) }) + child.once('error', (error) => { failure = error }) child.once('close', (code, signal) => { complete(() => { + if (failure !== undefined) { reject(failure); return } if (code === 0) { settle() return @@ -630,6 +500,13 @@ export class DesktopProjectManager { )) }) }) + try { + if (child.pid === undefined) throw new Error('desktop project: pnpm did not report a process id') + this.writeLockOwner(child.pid) + } catch (error) { + failure = errorOf(error, 'desktop project: failed to assign the package transaction lock to pnpm') + child.kill('SIGKILL') + } }) } @@ -643,7 +520,8 @@ export class DesktopProjectManager { } private async withLock(operation: () => Promise): Promise { - mkdirSync(this.paths.root, { recursive: true, mode: 0o700 }) + mkdirSync(this.paths.profile, { recursive: true, mode: 0o700 }) + if (lstatSync(this.paths.profile).isSymbolicLink()) throw new Error('desktop project: profile directory must not be a link') let descriptor: number try { descriptor = openSync(this.paths.lock, 'wx', 0o600) @@ -682,10 +560,10 @@ export class DesktopProjectManager { } } -/** Create seed metadata for one exact Electron and dsh release. */ -export function createSeedMetadata(seedDir: string, release: DesktopRelease): void { - mkdirSync(seedDir, { recursive: true, mode: 0o700 }) - const packageSet = verifyDesktopCorePackageSet(seedDir, release.version) +/** Create build-only project metadata for materializing the signed runtime. */ +export function createRuntimeProjectMetadata(projectDir: string, release: DesktopRelease): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + const packageSet = verifyDesktopCorePackageSet(projectDir, release.version) const manifest: DesktopProjectManifest = { name: PROJECT_NAME, private: true, @@ -693,13 +571,12 @@ export function createSeedMetadata(seedDir: string, release: DesktopRelease): vo dependencies: desktopCorePackageOverrides(packageSet), dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, } - writeJson(join(seedDir, 'package.json'), manifest) + writeJson(join(projectDir, 'package.json'), manifest) writeFileSync( - join(seedDir, 'pnpm-workspace.yaml'), + join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(desktopCorePackageOverrides(packageSet)), { mode: 0o600 }, ) - writeJson(join(seedDir, 'desktop-release.json'), release) } /** @@ -721,5 +598,14 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De } writeJson(join(projectDir, 'package.json'), manifest) writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) - writeJson(join(projectDir, 'desktop-release.json'), release) +} + +/** Create the first external plugin profile without running a package manager. */ +export function createPluginProfile(projectDir: string): void { + mkdirSync(projectDir, { recursive: true, mode: 0o700 }) + writeJson(join(projectDir, 'package.json'), { + name: PROJECT_NAME, private: true, version: '0.0.0', dependencies: {}, + dsh: { profile: { bundles: [...DESKTOP_PROFILE_BUNDLES] } }, + } satisfies DesktopProjectManifest) + writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 }) } diff --git a/apps/desktop/src/release.ts b/apps/desktop/src/release.ts index f78d922e8c..88050120e5 100644 --- a/apps/desktop/src/release.ts +++ b/apps/desktop/src/release.ts @@ -1,9 +1,9 @@ -/** Immutable version identity shared by one Electron shell and its dsh seed. */ +/** Immutable version identity shared by one Electron shell and its bundled dsh runtime. */ import { valid } from 'semver' import { DESKTOP_HOST_PROTOCOL_VERSION } from './host-protocol.ts' -/** Release facts embedded in the seed and copied into the active desktop project. */ +/** Release facts embedded in the bundled runtime descriptor. */ export interface DesktopRelease { readonly schemaVersion: 1 /** Exact version used by both Electron and `@deepseek-ai/dsh`. */ diff --git a/apps/desktop/src/runtime-tree.ts b/apps/desktop/src/runtime-tree.ts new file mode 100644 index 0000000000..3e4c352cb6 --- /dev/null +++ b/apps/desktop/src/runtime-tree.ts @@ -0,0 +1,222 @@ +/** Relocatable, integrity-recorded production packages carried by one Desktop release. */ + +import { createHash } from 'node:crypto' +import { lstatSync, readdirSync, readFile, readFileSync, writeFileSync } from 'node:fs' +import { isAbsolute, join, relative, sep } from 'node:path' +import { promisify } from 'node:util' +import { valid } from 'semver' +import { DESKTOP_HOST_PACKAGE } from './core-package-set.ts' +import { parseDesktopRelease, type DesktopRelease } from './release.ts' + +/** Descriptor at the root of the immutable Desktop resource tree. */ +export const DESKTOP_RUNTIME_FILE = 'desktop-runtime.json' + +/** Host-owned package available to external plugins through a directory link. */ +export interface DesktopSharedPackage { + readonly name: string + readonly version: string + readonly path: string +} + +/** Final bytes and executable permissions of a runtime file. */ +export interface DesktopRuntimeFile { + readonly path: string + readonly bytes: number + readonly sha256: string + readonly executable: boolean +} + +/** One signed application's production dependency tree. */ +export interface DesktopRuntimeDescriptor { + readonly schemaVersion: 1 + readonly release: DesktopRelease + readonly platform: NodeJS.Platform + readonly arch: string + readonly sharedPackages: readonly DesktopSharedPackage[] + readonly files: readonly DesktopRuntimeFile[] +} + +const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)$/u +const readRuntimeFile = promisify(readFile) + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Resolve one portable resource path without permitting traversal or absolute paths. + * @param root - Runtime root. + * @param path - Slash-separated relative path from durable metadata. + * @returns Absolute resource path. + */ +export function runtimePath(root: string, path: string): string { + if (path === '' || isAbsolute(path) || path.includes('\\') || path.includes(':') + || path.split('/').some(part => part === '' || part === '.' || part === '..')) { + throw new Error(`desktop runtime: invalid relative path ${JSON.stringify(path)}`) + } + return join(root, ...path.split('/')) +} + +function runtimeFiles(root: string): { path: string; name: string }[] { + const files: { path: string; name: string }[] = [] + const visit = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const name = relative(root, path).split(sep).join('/') + if (name === DESKTOP_RUNTIME_FILE) continue + if (entry.isDirectory()) visit(path) + else if (entry.isFile()) files.push({ path, name }) + else throw new Error(`desktop runtime: unsupported filesystem entry ${name}`) + } + } + visit(root) + return files +} + +function runtimeFile(path: string, name: string, body: Buffer): DesktopRuntimeFile { + return { path: name, bytes: body.byteLength, sha256: createHash('sha256').update(body).digest('hex'), + // Windows has no portable Unix executable permission bits. + executable: process.platform !== 'win32' && (lstatSync(path).mode & 0o111) !== 0 } +} + +/** + * Inventory a materialized runtime without following links or including its descriptor. + * @param root - Self-contained runtime directory. + * @returns Sorted final-file inventory; executable permissions are false on Windows. + */ +export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] { + return runtimeFiles(root).map(({ path, name }) => runtimeFile(path, name, readFileSync(path))) + .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +async function inventoryRuntimeForVerification(root: string): Promise { + const entries = runtimeFiles(root) + const files: DesktopRuntimeFile[] = [] + const remaining = entries.values() + let failed = false + const workers = Array.from({ length: Math.min(8, entries.length) }, async () => { + while (!failed) { + const next = remaining.next() + if (next.done) return + const { path, name } = next.value + try { + files.push(runtimeFile(path, name, await readRuntimeFile(path))) + } catch (error) { + failed = true + throw error + } + } + }) + // Failure returns only after every outstanding file read has closed its descriptor. + const results = await Promise.allSettled(workers) + for (const result of results) if (result.status === 'rejected') throw result.reason + return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) +} + +/** + * Seal the final runtime tree after materialization and native signing. + * @param root - Runtime output directory. + * @param release - Matching shell, dsh, Host, and executable versions. + * @param sharedNames - Release-owned packages supplied to plugins. + * @param target - Platform and architecture selected by runtime preparation. + * @returns Descriptor written beside the production packages. + */ +export function writeDesktopRuntime( + root: string, release: DesktopRelease, sharedNames: readonly string[], + target: { platform: NodeJS.Platform; arch: string } = process, +): DesktopRuntimeDescriptor { + const sharedPackages = [...new Set(sharedNames)].sort().map((name) => { + if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`) + const path = `node_modules/${name}` + const manifest = JSON.parse(readFileSync(join(runtimePath(root, path), 'package.json'), 'utf8')) as unknown + if (!record(manifest) || manifest.name !== name || typeof manifest.version !== 'string' || valid(manifest.version) === null) { + throw new Error(`desktop runtime: invalid shared package manifest ${name}`) + } + return { name, version: manifest.version, path } + }) + const descriptor: DesktopRuntimeDescriptor = { + schemaVersion: 1, release, platform: target.platform, arch: target.arch, + sharedPackages, files: inventoryDesktopRuntime(root), + } + writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`) + return descriptor +} + +/** + * Read packaged metadata and check shared package records. + * @param root - Current application's runtime resources. + * @returns Runtime metadata whose release compatibility is verified during packaging. + */ +export function readDesktopRuntime(root: string): DesktopRuntimeDescriptor { + const value: unknown = JSON.parse(readFileSync(join(root, DESKTOP_RUNTIME_FILE), 'utf8')) + if (!record(value) || typeof value.platform !== 'string' || typeof value.arch !== 'string' + || !Array.isArray(value.sharedPackages) || !Array.isArray(value.files)) { + throw new Error('desktop runtime: invalid descriptor') + } + if (!record(value.release) || typeof value.release.version !== 'string' + || typeof value.release.nodeVersion !== 'string' || typeof value.release.pnpmVersion !== 'string') { + throw new Error('desktop runtime: invalid release fields') + } + const release = value.release as unknown as DesktopRelease + const sharedPackages = value.sharedPackages.map((entry: unknown): DesktopSharedPackage => { + if (!record(entry) || typeof entry.name !== 'string' || !PACKAGE_NAME.test(entry.name) + || typeof entry.version !== 'string' || valid(entry.version) === null || entry.path !== `node_modules/${entry.name}`) { + throw new Error('desktop runtime: invalid shared package record') + } + return { name: entry.name, version: entry.version, path: entry.path } + }) + if (new Set(sharedPackages.map(entry => entry.name)).size !== sharedPackages.length) { + throw new Error('desktop runtime: duplicate shared package') + } + const files = value.files as DesktopRuntimeFile[] + for (const name of ['@deepseek-ai/dsh', DESKTOP_HOST_PACKAGE]) { + if (sharedPackages.find(entry => entry.name === name)?.version !== release.version) { + throw new Error(`desktop runtime: missing or mismatched ${name}`) + } + } + return { schemaVersion: value.schemaVersion as 1, release, platform: value.platform as NodeJS.Platform, + arch: value.arch, sharedPackages, files } +} + +/** + * Verify every packaged runtime file against its recorded bytes and permissions at build time. + * @param root - Materialized runtime resources. + * @param electronVersion - Expected shell version. + * @param target - Required execution target; defaults to the current process. + * @returns Validated runtime descriptor. + */ +export async function verifyDesktopRuntime( + root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process, +): Promise { + const descriptor = readDesktopRuntime(root) + // readDesktopRuntime preserves the disk schema value without validating release compatibility. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (descriptor.schemaVersion !== 1 || descriptor.platform !== target.platform || descriptor.arch !== target.arch) { + throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture') + } + const release = parseDesktopRelease(descriptor.release) + if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`) + for (const entry of descriptor.sharedPackages) { + const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8')) + if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) { + throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`) + } + } + const actual = await inventoryRuntimeForVerification(root) + // Windows has no portable Unix executable permission bits. + const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32' + ? items.map(({ executable: _executable, ...item }) => item) : items + if (JSON.stringify(comparable(descriptor.files)) !== JSON.stringify(comparable(actual))) { + throw new Error('desktop runtime: integrity verification failed') + } + return descriptor +} + +/** + * Identify exact runtime content independently of its installation path. + * @param descriptor - Validated runtime metadata. + * @returns SHA-256 runtime identity. + */ +export function desktopRuntimeId(descriptor: DesktopRuntimeDescriptor): string { + return createHash('sha256').update(JSON.stringify(descriptor)).digest('hex') +} diff --git a/apps/desktop/src/seed-store.ts b/apps/desktop/src/seed-store.ts deleted file mode 100644 index e4b4759fd1..0000000000 --- a/apps/desktop/src/seed-store.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** Deterministic archive transport for the desktop seed's pnpm store. */ - -import { createHash } from 'node:crypto' -import { - chmodSync, - copyFileSync, - cpSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { join, relative, sep } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { create, extract, list } from 'tar' - -/** Directory containing the seed's uncompressed pnpm store archives. */ -export const SEED_STORE_ARCHIVE_DIR = 'store-archives' - -/** Manifest describing the deterministic pnpm store archive set. */ -export const SEED_STORE_ARCHIVE_MANIFEST = 'store-archives.json' - -const DEFAULT_SHARD_COUNT = 16 -const ARCHIVE_NAME_PATTERN = /^store-[0-9a-f]{2}\.tar$/u -const STORE_VERSION_PATTERN = /^v\d+$/u - -interface SeedStoreArchiveRecord { - readonly file: string - readonly entries: number -} - -interface SeedStoreArchiveManifest { - readonly schemaVersion: 1 - readonly shardCount: number - readonly archives: readonly SeedStoreArchiveRecord[] -} - -function storeFiles(storeRoot: string): readonly string[] { - const files: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isSymbolicLink()) { - throw new Error(`desktop seed: pnpm store contains a symbolic link: ${relative(storeRoot, path)}`) - } - if (entry.isDirectory()) { - visit(path) - continue - } - if (!entry.isFile()) { - throw new Error(`desktop seed: pnpm store contains an unsupported file: ${relative(storeRoot, path)}`) - } - files.push(relative(storeRoot, path).split(sep).join('/')) - } - } - visit(storeRoot) - return files.sort((left, right) => left.localeCompare(right)) -} - -function shardFor(path: string, shardCount: number): number { - return createHash('sha256').update(path).digest().readUInt32BE(0) % shardCount -} - -function readArchiveManifest(seedRoot: string): SeedStoreArchiveManifest { - const path = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) - const value = JSON.parse(readFileSync(path, 'utf8')) as unknown - if (typeof value !== 'object' || value === null) { - throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) - } - const candidate = value as Record - if (candidate.schemaVersion !== 1 || !Number.isSafeInteger(candidate.shardCount) - || (candidate.shardCount as number) < 1 || (candidate.shardCount as number) > 256 - || !Array.isArray(candidate.archives) || candidate.archives.length === 0) { - throw new Error(`desktop seed: invalid pnpm store archive manifest ${path}`) - } - const names = new Set() - const archives = candidate.archives.map((entry): SeedStoreArchiveRecord => { - if (typeof entry !== 'object' || entry === null) { - throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) - } - const record = entry as Record - if (typeof record.file !== 'string' || !ARCHIVE_NAME_PATTERN.test(record.file) - || names.has(record.file) || !Number.isSafeInteger(record.entries) || (record.entries as number) < 1) { - throw new Error(`desktop seed: invalid pnpm store archive record in ${path}`) - } - const shard = Number.parseInt(record.file.slice('store-'.length, -'.tar'.length), 16) - if (shard >= (candidate.shardCount as number)) { - throw new Error(`desktop seed: pnpm store archive shard is outside the manifest range in ${path}`) - } - names.add(record.file) - return { file: record.file, entries: record.entries as number } - }) - return { - schemaVersion: 1, - shardCount: candidate.shardCount as number, - archives, - } -} - -function assertArchivePath(path: string): void { - if (path === '' || path.startsWith('/') || path.includes('\\') || path.includes('\0') - || path.split('/').some(part => part === '' || part === '.' || part === '..')) { - throw new Error(`desktop seed: unsafe pnpm store archive path ${JSON.stringify(path)}`) - } -} - -/** - * Remove pnpm's registrations for projects that populated the seed store. - * @param storeRoot - pnpm store directory included in the desktop seed. - */ -export function removePnpmProjectRegistrations(storeRoot: string): void { - for (const entry of readdirSync(storeRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !/^v\d+$/u.test(entry.name)) continue - rmSync(join(storeRoot, entry.name, 'projects'), { recursive: true, force: true }) - } -} - -function mergeStoreIndex(source: string, destination: string): void { - if (!existsSync(destination)) { - copyFileSync(source, destination) - return - } - const database = new DatabaseSync(destination) - let attached = false - try { - database.exec('PRAGMA busy_timeout=5000') - database.prepare('ATTACH DATABASE ? AS seed').run(source) - attached = true - database.exec('BEGIN IMMEDIATE') - let committed = false - try { - database.exec('INSERT OR REPLACE INTO package_index (key, data) SELECT key, data FROM seed.package_index') - database.exec('COMMIT') - committed = true - } finally { - if (!committed) database.exec('ROLLBACK') - } - } finally { - if (attached) database.exec('DETACH DATABASE seed') - database.close() - } -} - -/** - * Merge a completely extracted seed store into Desktop's persistent pnpm store. - * @param source - Verified temporary store extraction. - * @param destination - Desktop-owned persistent pnpm store. - */ -export function mergePnpmStore(source: string, destination: string): void { - mkdirSync(destination, { recursive: true, mode: 0o700 }) - const indexPaths = readdirSync(source, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && STORE_VERSION_PATTERN.test(entry.name) - && existsSync(join(source, entry.name, 'index.db'))) - .map(entry => `${entry.name}/index.db`) - const indexes = new Set(indexPaths) - cpSync(source, destination, { - recursive: true, - force: true, - filter: path => !indexes.has(relative(source, path).split(sep).join('/')), - }) - for (const path of indexPaths) { - mergeStoreIndex(join(source, ...path.split('/')), join(destination, ...path.split('/'))) - } -} - -/** - * Replace a prepared loose pnpm store with deterministic uncompressed archive shards. - * @param seedRoot - seed directory that owns the archive output. - * @param storeRoot - populated pnpm store to archive and remove after success. - * @param shardCount - stable shard count used to limit update churn. - */ -export function archivePnpmStore( - seedRoot: string, - storeRoot: string, - shardCount = DEFAULT_SHARD_COUNT, -): void { - if (!Number.isSafeInteger(shardCount) || shardCount < 1 || shardCount > 256) { - throw new Error(`desktop seed: invalid pnpm store shard count ${shardCount}`) - } - const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) - const manifestPath = join(seedRoot, SEED_STORE_ARCHIVE_MANIFEST) - rmSync(archiveRoot, { recursive: true, force: true }) - rmSync(manifestPath, { force: true }) - mkdirSync(archiveRoot, { recursive: true }) - const shards = Array.from({ length: shardCount }, (): string[] => []) - for (const path of storeFiles(storeRoot)) (shards[shardFor(path, shardCount)] as string[]).push(path) - const archives: SeedStoreArchiveRecord[] = [] - for (const [index, paths] of shards.entries()) { - if (paths.length === 0) continue - const file = `store-${index.toString(16).padStart(2, '0')}.tar` - create({ - cwd: storeRoot, - file: join(archiveRoot, file), - noDirRecurse: true, - noMtime: true, - portable: true, - sync: true, - }, paths) - chmodSync(join(archiveRoot, file), 0o644) - archives.push({ file, entries: paths.length }) - } - if (archives.length === 0) throw new Error('desktop seed: pnpm store is empty') - writeFileSync(manifestPath, `${JSON.stringify({ schemaVersion: 1, shardCount, archives }, undefined, 2)}\n`) - rmSync(storeRoot, { recursive: true }) -} - -/** - * Validate and extract a packaged pnpm store archive set into an empty directory. - * @param seedRoot - verified packaged seed directory. - * @param destination - empty Desktop-owned temporary extraction directory. - */ -export function extractPnpmStoreArchives(seedRoot: string, destination: string): void { - const manifest = readArchiveManifest(seedRoot) - const archiveRoot = join(seedRoot, SEED_STORE_ARCHIVE_DIR) - const actualFiles = readdirSync(archiveRoot, { withFileTypes: true }).map((entry) => { - if (!entry.isFile() || entry.isSymbolicLink()) { - throw new Error(`desktop seed: invalid pnpm store archive entry ${entry.name}`) - } - return entry.name - }).sort() - const expectedFiles = manifest.archives.map(archive => archive.file).sort() - if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { - throw new Error('desktop seed: pnpm store archive set does not match its manifest') - } - if (existsSync(destination) && readdirSync(destination).length !== 0) { - throw new Error(`desktop seed: pnpm store extraction directory is not empty: ${destination}`) - } - mkdirSync(destination, { recursive: true, mode: 0o700 }) - const paths = new Set() - for (const archive of manifest.archives) { - const archivePath = join(archiveRoot, archive.file) - const archiveShard = Number.parseInt(archive.file.slice('store-'.length, -'.tar'.length), 16) - let entries = 0 - list({ - file: archivePath, - onReadEntry: (entry) => { - if (entry.type !== 'File' && entry.type !== 'OldFile') { - throw new Error(`desktop seed: unsupported pnpm store archive entry type ${entry.type}`) - } - assertArchivePath(entry.path) - if (shardFor(entry.path, manifest.shardCount) !== archiveShard) { - throw new Error(`desktop seed: pnpm store path is assigned to the wrong archive shard: ${entry.path}`) - } - if (paths.has(entry.path)) { - throw new Error(`desktop seed: duplicate pnpm store archive path ${entry.path}`) - } - paths.add(entry.path) - entries += 1 - }, - strict: true, - sync: true, - }) - if (entries !== archive.entries) { - throw new Error(`desktop seed: pnpm store archive ${archive.file} has an unexpected entry count`) - } - } - for (const archive of manifest.archives) { - extract({ - chmod: true, - cwd: destination, - file: join(archiveRoot, archive.file), - noMtime: true, - preservePaths: false, - processUmask: 0, - strict: true, - sync: true, - }) - } -} diff --git a/apps/desktop/src/startup-document.ts b/apps/desktop/src/startup-document.ts new file mode 100644 index 0000000000..70053a8a5f --- /dev/null +++ b/apps/desktop/src/startup-document.ts @@ -0,0 +1,26 @@ +/** Self-contained recovery document for an unavailable shell renderer or preload. */ + +import type { DesktopLocale } from './locale.ts' + +/** + * Render escaped diagnostics without depending on application resource files. + * @param locale - Shell-owned translations. + * @param message - Failure details displayed as plain text. + * @param profileRecovery - Whether the initialized application can repair its profile. + * @returns An HTML document suitable for an isolated emergency window. + */ +export function startupFailureDocument(locale: DesktopLocale, message: string, profileRecovery = false): string { + const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') + return ` + +${escape(locale.messages.startupFailed)} + +

${escape(locale.messages.startupFailed)}

${escape(locale.messages.startupReinstallAdvice)}

+${profileRecovery ? `

${escape(locale.messages.startupConfigurationAdvice)}

` : ''} +
${escape(message)}
+
+${profileRecovery ? `
+
` : ''} +
` +} diff --git a/apps/desktop/src/startup-error.ts b/apps/desktop/src/startup-error.ts new file mode 100644 index 0000000000..9c7c4c1d08 --- /dev/null +++ b/apps/desktop/src/startup-error.ts @@ -0,0 +1,13 @@ +/** Serializable Desktop failure diagnostics. */ + +/** + * Preserve nested diagnostics when sending failures to a renderer. + * @param error - Startup or runtime failure. + * @returns Serializable error state. + */ +export function desktopErrorState(error: unknown): { phase: 'error'; message: string } { + const message = error instanceof AggregateError + ? [error.message, ...error.errors.map(item => desktopErrorState(item).message)].join('\n') + : error instanceof Error ? error.message : String(error) + return { phase: 'error', message } +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index c31dcc3093..be629228d8 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -1,4 +1,4 @@ -/** One Electron release stream for the version-bound shell and dsh seed. */ +/** One Electron release stream for the version-bound shell and bundled dsh runtime. */ import { existsSync } from 'node:fs' import { join } from 'node:path' diff --git a/apps/desktop/tests/backend-controller.spec.ts b/apps/desktop/tests/backend-controller.spec.ts new file mode 100644 index 0000000000..abdc78a9e7 --- /dev/null +++ b/apps/desktop/tests/backend-controller.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { DesktopBackendController, type DesktopBackendState } from '../src/backend-controller.ts' + +function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((accept, decline) => { resolve = accept; reject = decline }) + return { promise, resolve, reject } +} + +function fixture() { + const started = deferred() + const ready = deferred() + const stopping = deferred() + const exited = deferred() + const states: DesktopBackendState[] = [] + let fail!: (error: Error) => void + const host = { + start: vi.fn(() => { started.resolve(); return ready.promise }), + stop: vi.fn(() => { stopping.resolve(); return exited.promise }), + } + const create = vi.fn((onFailure: (error: Error) => void) => { fail = onFailure; return host }) + const controller = new DesktopBackendController(create, state => states.push(state)) + return { controller, host, create, states, started, ready, stopping, exited, fail: (error: Error) => { fail(error) } } +} + +describe('desktop backend controller', () => { + it('shares preparation and startup between concurrent retries', async () => { + const f = fixture() + const prepare = vi.fn(async () => {}) + const first = f.controller.start(prepare) + expect(f.controller.start(prepare)).toBe(first) + await f.started.promise + expect(f.controller.host).toBeUndefined() + expect(f.states).toEqual([{ phase: 'starting' }]) + f.ready.resolve() + await first + expect(f.controller.host).toBe(f.host) + await f.controller.start(prepare) + expect(prepare).toHaveBeenCalledTimes(1) + expect(f.host.start).toHaveBeenCalledTimes(1) + f.exited.resolve() + await f.controller.close() + }) + + it('waits for pending preparation on close and never spawns afterward', async () => { + const f = fixture() + const preparing = deferred() + const prepared = deferred() + const start = f.controller.start(() => { preparing.resolve(); return prepared.promise }) + await preparing.promise + let closed = false + const close = f.controller.close().then(() => { closed = true }) + await Promise.resolve() + expect(closed).toBe(false) + prepared.resolve() + await Promise.all([start, close]) + expect(f.create).not.toHaveBeenCalled() + expect(f.states).toEqual([{ phase: 'starting' }]) + await expect(f.controller.start(async () => {})).rejects.toThrow('closed') + }) + + it('stops a pending child once and waits for startup and child exit on close', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('stopped') + await f.started.promise + const close = f.controller.close() + expect(f.controller.close()).toBe(close) + await f.stopping.promise + let closed = false + void close.then(() => { closed = true }) + f.ready.reject(new Error('stopped')) + await Promise.resolve() + expect(closed).toBe(false) + f.exited.resolve() + await Promise.all([rejected, close]) + expect(f.host.stop).toHaveBeenCalledTimes(1) + expect(f.states).toEqual([{ phase: 'starting' }]) + expect(f.controller.host).toBeUndefined() + }) + + it('publishes preparation errors and permits retry', async () => { + const f = fixture() + await expect(f.controller.start(async () => { throw new Error('invalid profile') })).rejects.toThrow('invalid profile') + expect(f.controller.state).toEqual({ phase: 'error', message: 'invalid profile' }) + expect(f.create).not.toHaveBeenCalled() + f.ready.resolve() + await f.controller.start(async () => {}) + expect(f.controller.state).toEqual({ phase: 'ready' }) + f.exited.resolve() + await f.controller.close() + }) + + it('cleans up a failed startup before rejecting and displays the actual failure', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('plugin failed') + await f.started.promise + f.ready.reject(new Error('plugin failed')) + await f.stopping.promise + f.exited.resolve() + await rejected + expect(f.controller.state).toEqual({ phase: 'error', message: 'plugin failed' }) + expect(f.host.stop).toHaveBeenCalledTimes(1) + await f.controller.close() + }) + + it('waits for failed ready child cleanup before preparing a retry', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + f.fail(new Error('transport failed')) + expect(f.controller.state).toEqual({ phase: 'error', message: 'transport failed' }) + expect(f.controller.host).toBeUndefined() + await f.stopping.promise + const prepare = vi.fn(async () => {}) + const retry = f.controller.start(prepare) + await Promise.resolve() + expect(prepare).not.toHaveBeenCalled() + f.exited.resolve() + await retry + expect(prepare).toHaveBeenCalledTimes(1) + expect(f.create).toHaveBeenCalledTimes(2) + await f.controller.close() + }) + + it('does not publish ready when the child fails as readiness settles', async () => { + const f = fixture() + const start = f.controller.start(async () => {}) + const rejected = expect(start).rejects.toThrow('immediate failure') + await f.started.promise + f.ready.resolve() + f.fail(new Error('immediate failure')) + await f.stopping.promise + f.exited.resolve() + await rejected + expect(f.states).toEqual([{ phase: 'starting' }, { phase: 'error', message: 'immediate failure' }]) + await f.controller.close() + }) + + it('ignores failure callbacks caused by intentional stop and permits subsequent start', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + const stop = f.controller.stop() + await f.stopping.promise + f.fail(new Error('stopped')) + await expect(f.controller.start(async () => {})).rejects.toThrow('stopping') + expect(f.controller.state).toEqual({ phase: 'starting' }) + f.exited.resolve() + await stop + await f.controller.start(async () => {}) + expect(f.create).toHaveBeenCalledTimes(2) + await f.controller.close() + }) + + it('propagates cleanup failure and prevents retry from overlapping the remaining child', async () => { + const f = fixture() + f.ready.resolve() + await f.controller.start(async () => {}) + f.fail(new Error('fatal')) + await f.stopping.promise + f.exited.reject(new Error('child did not exit')) + await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit') + await expect(f.controller.start(async () => {})).rejects.toThrow('child did not exit') + expect(f.create).toHaveBeenCalledTimes(1) + await expect(f.controller.close()).rejects.toThrow('child did not exit') + }) +}) diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index ac1618baef..1a309a2851 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -15,8 +15,8 @@ describe('desktop build paths', () => { 'artifacts', 'runtime', 'packageSet', - 'seed', - 'seedPnpm', + 'dsh', + 'dshPnpm', 'nodeExtract', 'packedDsh', 'packedVendor', @@ -27,7 +27,7 @@ describe('desktop build paths', () => { expect(new Set([arm64[key], x64[key], windows[key]]).size).toBe(3) } expect(arm64.artifacts).toContain(join('targets', 'mac-arm64', 'artifacts')) - expect(x64.seed).toContain(join('targets', 'mac-x64', 'seed')) + expect(x64.dsh).toContain(join('targets', 'mac-x64', 'dsh')) expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime')) }) diff --git a/apps/desktop/tests/expected/startup-en-profile.txt b/apps/desktop/tests/expected/startup-en-profile.txt new file mode 100644 index 0000000000..29bfba4506 --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-profile.txt @@ -0,0 +1,8 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart +Disable all third-party plugins and retry +Reset Desktop and retry diff --git a/apps/desktop/tests/expected/startup-en-restart.txt b/apps/desktop/tests/expected/startup-en-restart.txt new file mode 100644 index 0000000000..af2ea1dddc --- /dev/null +++ b/apps/desktop/tests/expected/startup-en-restart.txt @@ -0,0 +1,5 @@ +DeepSeek Harness could not start +Choose a recovery action below. Disabling third-party plugins retains their files. +If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. +Failure details +Close and restart diff --git a/apps/desktop/tests/expected/startup-zh-CN-profile.txt b/apps/desktop/tests/expected/startup-zh-CN-profile.txt new file mode 100644 index 0000000000..f37bdf417f --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-profile.txt @@ -0,0 +1,8 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 +禁用全部第三方插件并重试 +重置 Desktop 并重试 diff --git a/apps/desktop/tests/expected/startup-zh-CN-restart.txt b/apps/desktop/tests/expected/startup-zh-CN-restart.txt new file mode 100644 index 0000000000..c9fcb94f46 --- /dev/null +++ b/apps/desktop/tests/expected/startup-zh-CN-restart.txt @@ -0,0 +1,5 @@ +DeepSeek Harness 无法启动 +请选择下方的恢复操作。禁用第三方插件会保留插件文件。 +如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 +Failure details +关闭并重启 diff --git a/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi b/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi new file mode 100644 index 0000000000..2c1c47ae34 --- /dev/null +++ b/apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi @@ -0,0 +1,70 @@ +; Compile with OUTPUT_FILE and RESULT_FILE in a private test directory, then run silently. +Unicode true +RequestExecutionLevel user +SilentInstall silent +Name "Desktop installer cleanup smoke" +OutFile "${OUTPUT_FILE}" +!include "..\..\scripts\installer.nsh" + +Section + InitPluginsDir + SetOutPath "$PLUGINSDIR" + CreateDirectory "$PLUGINSDIR\7z-out\nested" + FileOpen $1 "$PLUGINSDIR\7z-out\nested\extracted-file" w + FileWrite $1 "remove" + FileClose $1 + FileOpen $1 "$PLUGINSDIR\archive.7z" w + FileWrite $1 "archive sentinel" + FileClose $1 + FileOpen $1 "$PLUGINSDIR\plugin.dll" w + FileWrite $1 "plugin sentinel" + FileClose $1 + CreateDirectory "$PLUGINSDIR\old-install" + FileOpen $1 "$PLUGINSDIR\old-install\sentinel" w + FileWrite $1 "rollback sentinel" + FileClose $1 + + StrCpy $0 "register sentinel" + ClearErrors + !insertmacro customInstall + IfErrors failed + StrCmp $0 "register sentinel" 0 failed + StrCmp $OUTDIR $PLUGINSDIR 0 failed + IfFileExists "$PLUGINSDIR\7z-out\*.*" failed + + FileOpen $1 "$PLUGINSDIR\archive.7z" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "archive sentinel" 0 failed + FileOpen $1 "$PLUGINSDIR\plugin.dll" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "plugin sentinel" 0 failed + FileOpen $1 "$PLUGINSDIR\old-install\sentinel" r + FileRead $1 $2 + FileClose $1 + StrCmp $2 "rollback sentinel" 0 failed + + CreateDirectory "$PLUGINSDIR\7z-out" + SetErrors + !insertmacro customInstall + IfErrors +2 + Goto failed + IfFileExists "$PLUGINSDIR\7z-out\*.*" failed + + ClearErrors + !insertmacro customInstall + IfErrors failed + FileOpen $1 "${RESULT_FILE}" w + FileWrite $1 "scratch removed; archive, plugin, rollback, registers and error flags preserved" + FileClose $1 + SetErrorLevel 0 + Quit + + failed: + FileOpen $1 "${RESULT_FILE}" w + FileWrite $1 "cleanup smoke failed" + FileClose $1 + SetErrorLevel 1 + Quit +SectionEnd diff --git a/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi b/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi new file mode 100644 index 0000000000..d7976e51ad --- /dev/null +++ b/apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi @@ -0,0 +1,55 @@ +; Compile with OUTPUT_FILE, RESULT_FILE, PAYLOAD_FILE, TARGET_DIR and PLUGIN_DIR. +; All paths must belong to a private test directory, except the NSIS plugin directory. +; PAYLOAD_FILE contains locked.txt="new runtime" and asset.txt="new asset". +; Before running /S, create TARGET_DIR\locked.txt="old runtime" and hold it open +; for reading with FileShare.Read until the native process exits. asset.txt is absent. +; Define DIRECT for direct extraction; otherwise extract to temporary files and copy. +; Both modes retain "old runtime" and write "new asset" under that lock. The copy +; sets errorFlag=true; direct extraction reports false despite the mixed versions. +; Exit code 0 alone cannot establish successful extraction; inspect RESULT_FILE. +Unicode true +RequestExecutionLevel user +SilentInstall silent +Name "Desktop occupied-file extraction smoke" +OutFile "${OUTPUT_FILE}" +!addplugindir /x86-unicode "${PLUGIN_DIR}" + +Section + InitPluginsDir + SetOutPath "${TARGET_DIR}" + !ifdef DIRECT + ClearErrors + Nsis7z::Extract "${PAYLOAD_FILE}" + !else + SetOutPath "$PLUGINSDIR\7z-out" + Nsis7z::Extract "${PAYLOAD_FILE}" + SetOutPath "${TARGET_DIR}" + ClearErrors + CopyFiles /SILENT "$PLUGINSDIR\7z-out\*" $OUTDIR + !endif + + StrCpy $0 "false" + IfErrors 0 +2 + StrCpy $0 "true" + ClearErrors + FileOpen $1 "${RESULT_FILE}" w + IfErrors failed + FileWrite $1 "errorFlag=$0$\r$\n" + FileOpen $2 "${TARGET_DIR}\locked.txt" r + IfErrors failed + FileRead $2 $3 + FileClose $2 + FileWrite $1 "locked=$3$\r$\n" + FileOpen $2 "${TARGET_DIR}\asset.txt" r + IfErrors failed + FileRead $2 $3 + FileClose $2 + FileWrite $1 "asset=$3$\r$\n" + FileClose $1 + SetErrorLevel 0 + Quit + + failed: + SetErrorLevel 1 + Quit +SectionEnd diff --git a/apps/desktop/tests/fixtures/owned-directory-smoke.mjs b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs new file mode 100644 index 0000000000..52315992ba --- /dev/null +++ b/apps/desktop/tests/fixtures/owned-directory-smoke.mjs @@ -0,0 +1,29 @@ +/** Built Desktop cleanup smoke; run with Electron and ELECTRON_RUN_AS_NODE=1. */ + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { removeOwnedDirectory } from '../../lib/types/owned-directory.js' +import { inventoryDesktopRuntime } from '../../lib/types/runtime-tree.js' + +assert.ok(process.versions.electron, 'This regression must run under Electron') +const root = mkdtempSync(join(tmpdir(), 'desktop-electron-cleanup-')) +try { + const resources = join(root, 'resources') + mkdirSync(resources) + writeFileSync(join(resources, 'sentinel'), 'installed runtime bytes') + const inventory = inventoryDesktopRuntime(resources) + const profile = join(root, 'rollback-profile') + const packages = join(profile, 'node_modules', '@unknown') + mkdirSync(packages, { recursive: true }) + symlinkSync(resources, join(packages, 'host-package'), process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(join(packages, 'private-file'), 'removed') + removeOwnedDirectory(profile) + assert.equal(existsSync(profile), false) + assert.deepEqual(inventoryDesktopRuntime(resources), inventory) + assert.equal(readFileSync(join(resources, 'sentinel'), 'utf8'), 'installed runtime bytes') + console.log(JSON.stringify({ electron: process.versions.electron, node: process.version, preservedLinkTarget: true })) +} finally { + removeOwnedDirectory(root) +} diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs new file mode 100644 index 0000000000..da2d5c478d --- /dev/null +++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs @@ -0,0 +1,138 @@ +/** Exercise filtered Desktop native and HTML dependencies under its bundled Node. */ + +import assert from 'node:assert/strict' +import { closeSync, mkdtempSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const runtime = process.argv[2] +assert.ok(runtime, 'Pass the filtered resources/dsh directory') +const root = resolve(runtime) +const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), 'utf8')) +assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the bundled Node version') +assert.equal(process.platform, descriptor.platform) +assert.equal(process.arch, descriptor.arch) +const requireRuntime = createRequire(join(root, 'package.json')) +const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-')) + +/** Spawn only a fixed Node program and await the terminal's drained exit event. */ +async function checkPty() { + const pty = requireRuntime('node-pty') + const script = join(scratch, 'pty.cjs') + writeFileSync(script, "process.stdout.write('runtime-payload-pty-ok\\n')\n", { flag: 'wx', mode: 0o600 }) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => ( + /^(?:path|systemroot|windir|comspec)$/iu.test(name) + ))) + Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch }) + const terminal = pty.spawn(process.execPath, [script], { cwd: scratch, env, cols: 80, rows: 24 }) + let output = '' + let exited = false + let timedOut = false + let exitSubscription + const exit = new Promise(resolveExit => { + exitSubscription = terminal.onExit(event => { + exited = true + resolveExit(event) + }) + }) + const dataSubscription = terminal.onData(data => { output += data }) + let timer + try { + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true + reject(new Error('Packaged PTY did not exit within 45 seconds')) + }, 45_000) + }) + const result = await Promise.race([exit, deadline]) + assert.equal(timedOut, false) + assert.ok(result.signal === undefined || result.signal === 0, 'PTY exited without a signal') + assert.equal(result.exitCode, 0) + assert.match(output, /runtime-payload-pty-ok/u) + } finally { + clearTimeout(timer) + dataSubscription.dispose() + try { + // node-pty's Windows natural-exit event closes output but leaves its ConPTY worker owned by kill(). + if (!exited || process.platform === 'win32') terminal.kill() + await exit + } finally { + exitSubscription.dispose() + } + } +} + +/** fs-ext implements seek on Windows through SetFilePointerEx and on POSIX through lseek. */ +function checkFsExt() { + const fsExt = requireRuntime('fs-ext') + const file = join(scratch, 'seek.txt') + writeFileSync(file, 'abcdef', { flag: 'wx', mode: 0o600 }) + const fd = openSync(file, 'r') + try { + assert.equal(fsExt.seekSync(fd, 2, fsExt.constants.SEEK_SET), 2) + const bytes = Buffer.alloc(4) + assert.equal(readSync(fd, bytes, 0, bytes.length, null), 4) + assert.equal(bytes.toString(), 'cdef') + } finally { + closeSync(fd) + } +} + +/** Resolve one system function through Koffi's packaged native module. */ +function checkKoffi() { + const koffi = requireRuntime('koffi') + const library = koffi.load(process.platform === 'win32' ? 'kernel32.dll' : null) + try { + const getPid = process.platform === 'win32' + ? library.func('uint32_t __stdcall GetCurrentProcessId(void)') + : library.func('int getpid(void)') + assert.equal(getPid(), process.pid) + } finally { + library.unload() + } +} + +/** Encode and decode a pixel through the packaged libvips binary. */ +async function checkSharp() { + const sharp = requireRuntime('sharp') + const pixel = Buffer.from([17, 103, 231]) + const png = await sharp(pixel, { raw: { width: 1, height: 1, channels: 3 } }).png().toBuffer() + const decoded = await sharp(png).raw().toBuffer({ resolveWithObject: true }) + assert.equal(decoded.info.width, 1) + assert.equal(decoded.info.height, 1) + assert.equal(decoded.info.channels, 3) + assert.deepEqual(decoded.data, pixel) +} + +/** Exercise Domino parsing through the HTML converter and GFM plugin used by web_fetch. */ +function checkHtml() { + const Turndown = requireRuntime('turndown') + const { gfm } = requireRuntime('@joplin/turndown-plugin-gfm') + const converter = new Turndown({ bulletListMarker: '-' }) + converter.use(gfm) + const markdown = converter.turndown('

A & B ©

  • first
  • second
' + + '
NameValue
x7
') + assert.match(markdown, /A & B ©/u) + assert.match(markdown, /-\s+first\n-\s+second/u) + assert.match(markdown, /\| Name \| Value \|/u) + assert.match(markdown, /\| x\s+\| 7\s+\|/u) +} + +try { + checkFsExt() + checkKoffi() + await checkSharp() + checkHtml() + await checkPty() +} finally { + // This private tree contains only fixture files; Windows may release handles after terminal exit. + await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }) +} + +// Natural event-loop drain includes node-pty's worker and console-list helper teardown. +process.once('beforeExit', () => { + console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch, + fsExt: true, koffi: true, sharp: true, html: true, pty: true })) +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index d3a08d78e0..f41650c26e 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -1,7 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { DesktopHostProcess } from '../src/host-process.ts' const roots: string[] = [] @@ -75,6 +75,51 @@ afterEach(() => { }) describe('desktop host process', () => { + it('reports a fatal event after readiness once and stops the child', async () => { + const runtime = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: '1.0.0' }) +function onRequestFrame(frame) { + if (frame.type === 1) process.send({ type: 'fatal', message: 'plugin unavailable' }) +} +`) + const failure = vi.fn() + const host = new DesktopHostProcess(process.execPath, runtime, runtime, undefined, process.env, failure) + try { + await host.start() + await expect(host.fetch(new Request('dsh-app://app/'))).rejects.toThrow('plugin unavailable') + await host.stop() + expect(failure).toHaveBeenCalledTimes(1) + expect(failure).toHaveBeenCalledWith(new Error('plugin unavailable')) + } finally { await host.stop() } + }) + + it('settles teardown when the executable cannot be spawned', async () => { + const runtime = projectWithHost('function onRequestFrame() {}') + const host = new DesktopHostProcess(join(runtime, 'missing-node'), runtime, runtime) + try { await expect(host.start()).rejects.toThrow() } finally { await host.stop() } + }) + + it('loads the resource entry with a separate profile and scrubs Node resolution overrides', async () => { + const runtime = projectWithHost(` +process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'split-runtime' }) +function onRequestFrame(frame) { + if (frame.type !== 1) return + responseStart(frame.streamId) + responseData(frame.streamId, JSON.stringify({runtime: process.argv[2], profile: process.argv[3], cwd: process.cwd(), nodePath: process.env.NODE_PATH})) + responseEnd(frame.streamId) +} +`) + const profile = mkdtempSync(join(tmpdir(), 'desktop-external-profile-')) + roots.push(profile) + const host = new DesktopHostProcess(process.execPath, runtime, profile, undefined, { + ...process.env, NODE_OPTIONS: '--invalid-desktop-test-option', NODE_PATH: '/unowned', + }) + try { + const response = await host.fetch(new Request('dsh-app://app/environment')) + expect(await response.json()).toEqual({ runtime, profile, cwd: realpathSync(profile) }) + } finally { await host.stop() } + }) + it('carries raw request and response bytes and shuts the child down cleanly', async () => { const project = projectWithHost(` const bodies = new Map() @@ -98,7 +143,7 @@ function answer(streamId) { `) const previous = process.env.NODE_OPTIONS process.env.NODE_OPTIONS = '--require /path/that-must-not-reach-the-child' - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { await expect(host.start()).resolves.toMatchObject({ dshVersion: 'clean' }) const response = await host.fetch(new Request('dsh-app://app/example', { method: 'POST', body: 'request' })) @@ -124,7 +169,7 @@ function onRequestFrame(frame) { responseEnd(frame.streamId) } `) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const response = await host.fetch(new Request('dsh-app://app/large')) const body = new Uint8Array(await response.arrayBuffer()) @@ -151,7 +196,7 @@ function onRequestFrame(frame) { start(controller) { controller.enqueue(Buffer.from('first')) }, cancel() { canceled = true }, }) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const request = new Request('dsh-app://app/early', { method: 'POST', @@ -184,7 +229,7 @@ function onRequestFrame(frame) { } } `) - const host = new DesktopHostProcess(process.execPath, project) + const host = new DesktopHostProcess(process.execPath, project, project) try { const canceled = await host.fetch(new Request('dsh-app://app/cancel')) await canceled.body?.cancel() @@ -202,7 +247,7 @@ process.send({ type: 'ready', protocolVersion: 3, dshVersion: 'invalid-frame' }) function onRequestFrame(frame) { if (frame.type === 1) responsePipe.write(Buffer.alloc(13)) } -`)) +`), projectWithHost('')) await invalid.start() await expect(invalid.fetch(new Request('dsh-app://app/invalid'))).rejects.toThrow(/invalid Host response frame marker/u) await invalid.stop().catch(() => undefined) @@ -210,7 +255,7 @@ function onRequestFrame(frame) { const earlyExit = new DesktopHostProcess(process.execPath, projectWithHost(` function onRequestFrame() {} process.exit(0) -`)) +`), projectWithHost('')) await expect(earlyExit.start()).rejects.toThrow(/response pipe ended/u) }) }) diff --git a/apps/desktop/tests/macos-notarized-application.spec.ts b/apps/desktop/tests/macos-notarized-application.spec.ts new file mode 100644 index 0000000000..38fc57054c --- /dev/null +++ b/apps/desktop/tests/macos-notarized-application.spec.ts @@ -0,0 +1,45 @@ +/** Verify application qualification commands without invoking Apple tools. */ + +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { verifyMacOSNotarizedApplication } from '../scripts/verify-macos-signature.mjs' + +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawnSync: vi.fn(), +})) + +const expected = { signingIdentity: 'Example Company (TEAMID1234)', teamId: 'TEAMID1234' } +const appPath = '/private build/DeepSeek Harness.app' +const commands = [ + ['/usr/bin/codesign', ['--verify', '--deep', '--strict', '--verbose=2', appPath]], + ['/usr/bin/codesign', ['--display', '--verbose=4', appPath]], + ['/usr/bin/xcrun', ['stapler', 'validate', appPath]], + ['/usr/sbin/spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]], +] as const + +afterEach(() => { vi.resetAllMocks() }) + +describe('notarized application qualification', () => { + it.each([undefined, 0, 1, 2, 3])('stops at failed command %s or verifies every qualification', (failedCommand) => { + let index = 0 + vi.mocked(spawnSync).mockImplementation(() => ({ + pid: 1, + output: [], + stdout: '', + stderr: `Authority=Developer ID Application: ${expected.signingIdentity}\nTeamIdentifier=${expected.teamId}\n`, + status: index++ === failedCommand ? 1 : 0, + signal: null, + })) + if (failedCommand === undefined) { + expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).not.toThrow() + } else { + expect(() => { verifyMacOSNotarizedApplication(appPath, expected) }).toThrow('exited with 1') + } + const calledCommands = commands.slice(0, failedCommand === undefined ? commands.length : failedCommand + 1) + expect(spawnSync).toHaveBeenCalledTimes(calledCommands.length) + for (const [index, [command, args]] of calledCommands.entries()) { + expect(spawnSync).toHaveBeenNthCalledWith(index + 1, command, args, { encoding: 'utf8' }) + } + }) +}) diff --git a/apps/desktop/tests/macos-runtime.spec.ts b/apps/desktop/tests/macos-runtime.spec.ts new file mode 100644 index 0000000000..c33b8d8cfb --- /dev/null +++ b/apps/desktop/tests/macos-runtime.spec.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { signMacOSRuntime } from '../scripts/macos-runtime.ts' +import { signMacOSRuntimeCode, verifyMacOSRuntimeCode } from '../scripts/verify-macos-signature.mjs' + +vi.mock('../scripts/verify-macos-signature.mjs', () => ({ signMacOSRuntimeCode: vi.fn(), verifyMacOSRuntimeCode: vi.fn() })) +const roots: string[] = [] +function root(): string { + const path = mkdtempSync(join(tmpdir(), 'desktop-signing-')) + roots.push(path) + return path +} +const identity = { signingIdentity: 'Example (TEAMID1234)', teamId: 'TEAMID1234' } +afterEach(() => { + vi.resetAllMocks() + for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true }) +}) +it('signs Mach-O files in their final locations and verifies each signature', async () => { + const path = root() + writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex')) + writeFileSync(join(path, 'source.js'), 'export {}') + await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1) + expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity) + expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity) +}) +it('awaits other signers before rejecting and permitting output cleanup', async () => { + const path = root() + for (const name of ['a.node', 'b.node']) writeFileSync(join(path, name), Buffer.from('cffaedfe00000000', 'hex')) + let release!: () => void + const barrier = new Promise((resolve) => { release = resolve }) + let started!: () => void + const ready = new Promise((resolve) => { started = resolve }) + vi.mocked(signMacOSRuntimeCode).mockImplementation(async (file) => { + if (file.endsWith('a.node')) throw new Error('sign failure') + started() + await barrier + }) + let completed = false + const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error }) + try { + await ready + expect(completed).toBe(false) + } finally { release() } + expect(await result).toBeInstanceOf(AggregateError) + expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity) +}) diff --git a/apps/desktop/tests/macos-seed-store.spec.ts b/apps/desktop/tests/macos-seed-store.spec.ts deleted file mode 100644 index 2155389262..0000000000 --- a/apps/desktop/tests/macos-seed-store.spec.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createHash } from 'node:crypto' -import { - appendFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { Packr } from 'msgpackr' -import { afterEach, describe, expect, it } from 'vitest' -import { - signMacOSSeedStore, - verifyMacOSSeedStore, -} from '../scripts/macos-seed-store.ts' - -const temporaryRoots: string[] = [] -const packr = new Packr({ moreTypes: true, useRecords: true }) -const SIGNING_ENVIRONMENT = { - signingIdentity: 'Example Company (TEAMID1234)', - teamId: 'TEAMID1234', -} - -function temporaryRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-seed-signing-test-')) - temporaryRoots.push(root) - return root -} - -function casPath(store: string, body: Buffer, executable = false): { digest: string; path: string } { - const digest = createHash('sha512').update(body).digest('hex') - return { - digest, - path: join(store, 'v11', 'files', digest.slice(0, 2), `${digest.slice(2)}${executable ? '-exec' : ''}`), - } -} - -function createStoreFile(path: string, body: Buffer): void { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, body) -} - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop macOS seed store signing', () => { - it('rehashes signed Mach-O content, rewrites every package reference, and prunes native orphans', async () => { - const store = temporaryRoot() - const native = Buffer.concat([Buffer.from('cffaedfe', 'hex'), Buffer.from('native-code')]) - const nativeCas = casPath(store, native) - createStoreFile(nativeCas.path, native) - const orphan = Buffer.concat([Buffer.from('cafebabe', 'hex'), Buffer.from('orphan')]) - const orphanCas = casPath(store, orphan) - createStoreFile(orphanCas.path, orphan) - const plain = Buffer.from('plain package content') - const plainCas = casPath(store, plain) - createStoreFile(plainCas.path, plain) - - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - for (const key of ['package-a', 'package-b']) { - insert.run(key, packr.pack({ - algo: 'sha512', - files: new Map([ - ['native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], - ['index.js', { checkedAt: 1, digest: plainCas.digest, mode: 0o644, size: plain.length }], - ]), - sideEffects: key === 'package-b' - ? new Map([['build', { added: new Map([ - ['built/native.node', { checkedAt: 1, digest: nativeCas.digest, mode: 0o644, size: native.length }], - ]) }]]) - : undefined, - })) - } - database.close() - - const result = await signMacOSSeedStore( - store, - 'com.example.desktop', - SIGNING_ENVIRONMENT, - { - signer: async (path, identifier) => { - expect(identifier).toBe(`com.example.desktop.seed.${nativeCas.digest.slice(0, 32)}`) - appendFileSync(path, 'signed') - }, - }, - ) - - expect(result).toEqual({ signedFiles: 1, prunedOrphans: 1, updatedIndexRows: 2 }) - expect(existsSync(nativeCas.path)).toBe(false) - expect(existsSync(orphanCas.path)).toBe(false) - expect(readFileSync(plainCas.path)).toEqual(plain) - - const updated = new DatabaseSync(join(store, 'v11', 'index.db'), { readOnly: true }) - const digests = [...updated.prepare('SELECT data FROM package_index').iterate() as Iterable<{ data: Uint8Array }>] - .flatMap((row) => { - const record = packr.unpack(row.data) as { - files: Map - sideEffects?: Map }> - } - return [ - record.files.get('native.node')?.digest, - ...[...(record.sideEffects?.values() ?? [])].map(effect => effect.added.get('built/native.node')?.digest), - ].filter((digest): digest is string => digest !== undefined) - }) - updated.close() - expect(new Set(digests).size).toBe(1) - expect(digests).toHaveLength(3) - expect(digests[0]).not.toBe(nativeCas.digest) - - const verified: string[] = [] - expect(verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, (path) => { verified.push(path) })).toBe(1) - expect(verified).toHaveLength(1) - }) - - it('bounds concurrent signing while allowing independent Mach-O files to overlap', async () => { - const store = temporaryRoot() - const files = new Map() - for (let index = 0; index < 6; index += 1) { - const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) - const nativeCas = casPath(store, body) - createStoreFile(nativeCas.path, body) - files.set(`native-${index}.node`, { - checkedAt: 1, - digest: nativeCas.digest, - mode: 0o644, - size: body.length, - }) - } - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - .run('package', packr.pack({ algo: 'sha512', files })) - database.close() - - let started = 0 - let active = 0 - let maximumActive = 0 - let releaseSigning = (): void => {} - const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) - let markFirstWaveReady = (): void => {} - const firstWaveReady = new Promise((resolve) => { markFirstWaveReady = resolve }) - const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 4, - signer: async () => { - started += 1 - active += 1 - maximumActive = Math.max(maximumActive, active) - if (started === 4) markFirstWaveReady() - try { - await signingReleased - } finally { - active -= 1 - } - }, - }) - - await firstWaveReady - expect({ started, active, maximumActive }).toEqual({ started: 4, active: 4, maximumActive: 4 }) - releaseSigning() - await expect(signing).resolves.toMatchObject({ signedFiles: 6, updatedIndexRows: 1 }) - expect({ started, active, maximumActive }).toEqual({ started: 6, active: 0, maximumActive: 4 }) - }) - - it('awaits active signers and preserves the store when one signer fails', async () => { - const store = temporaryRoot() - const originals: { digest: string; path: string }[] = [] - const files = new Map() - for (let index = 0; index < 2; index += 1) { - const body = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from(`native-${index}`)]) - const nativeCas = casPath(store, body) - originals.push(nativeCas) - createStoreFile(nativeCas.path, body) - files.set(`native-${index}.node`, { - checkedAt: 1, - digest: nativeCas.digest, - mode: 0o644, - size: body.length, - }) - } - const databasePath = join(store, 'v11', 'index.db') - const database = new DatabaseSync(databasePath) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - .run('package', packr.pack({ algo: 'sha512', files })) - database.close() - const originalIndex = readFileSync(databasePath) - - let started = 0 - let settled = 0 - let releaseSigning = (): void => {} - const signingReleased = new Promise((resolve) => { releaseSigning = resolve }) - let markBothReady = (): void => {} - const bothReady = new Promise((resolve) => { markBothReady = resolve }) - const signing = signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 2, - signer: async () => { - started += 1 - const call = started - if (started === 2) markBothReady() - try { - await signingReleased - if (call === 1) throw new Error('signing failed') - } finally { - settled += 1 - } - }, - }) - - await bothReady - releaseSigning() - await expect(signing).rejects.toThrow(/signing failed/u) - expect({ started, settled }).toEqual({ started: 2, settled: 2 }) - expect(readFileSync(databasePath)).toEqual(originalIndex) - for (const original of originals) expect(existsSync(original.path)).toBe(true) - }) - - it('rejects an invalid signing worker bound before starting a signer', async () => { - const store = temporaryRoot() - mkdirSync(join(store, 'v11', 'files'), { recursive: true }) - await expect(signMacOSSeedStore(store, 'com.example.desktop', SIGNING_ENVIRONMENT, { - concurrency: 0, - signer: async () => {}, - })).rejects.toThrow(/positive integer/u) - }) - - it('propagates a signature-verification failure', () => { - const store = temporaryRoot() - const native = Buffer.concat([Buffer.from('feedfacf', 'hex'), Buffer.from('native-code')]) - const nativeCas = casPath(store, native) - createStoreFile(nativeCas.path, native) - - expect(() => { - verifyMacOSSeedStore(store, SIGNING_ENVIRONMENT, () => { - throw new Error('invalid signature') - }) - }).toThrow(/invalid signature/u) - }) -}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 827de532e9..e481d9a9f0 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -1,3 +1,10 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { createRequire } from 'node:module' +import { FileMatcher } from 'app-builder-lib/out/fileMatcher.js' +import { runtimeFixture } from './runtime-fixture.ts' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import type { NotarizeOptions } from '@electron/notarize' import { @@ -7,10 +14,15 @@ import { } from '../scripts/desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from '../scripts/notarize-macos-disk-images.mjs' import { - assertMacOSSeedSignatureDetails, + assertMacOSRuntimeSignatureDetails, assertMacOSSignatureDetails, } from '../scripts/verify-macos-signature.mjs' +// app-builder-lib omits this internal copier from its declarations; the regression exercises its actual file filter. +const { copyFiles } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as { + copyFiles: (matchers: FileMatcher[]) => Promise +} + const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_TARGET_PLATFORM: 'darwin', @@ -40,17 +52,18 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') - expect(config.extraResources).toHaveLength(2) + expect(config.extraResources).toHaveLength(3) expect(config.extraResources[0]?.to).toBe('runtime') - expect(config.extraResources[1]?.to).toBe('seed') + expect(config.extraResources[1]?.to).toBe('dsh') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') - expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/seed') + expect(portablePath(config.extraResources[1]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/dsh') expect(config).toMatchObject({ appId: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, mac: { identity: RELEASE_ENVIRONMENT.DSH_DESKTOP_MACOS_SIGNING_IDENTITY, forceCodeSigning: true, notarize: true, + signIgnore: ['/Contents/Resources/dsh(?:/|$)', '\\.pak$'], }, dmg: { sign: true, @@ -64,6 +77,41 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('seals PAK resources with their enclosing bundle while signing executable code', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + const ignored = (path: string): boolean => config.mac.signIgnore.some(pattern => new RegExp(pattern).test(path)) + expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/en.lproj/locale.pak')).toBe(true) + expect(ignored('/App.app/Contents/Frameworks/Electron.framework/Versions/A/Resources/resources.pak')).toBe(true) + for (const path of [ + '/App.app/Contents/Resources/runtime/node/node', + '/App.app/Contents/Resources/runtime/pnpm/addon.node', + '/App.app/Contents/Frameworks/Electron.framework/Versions/A/library.dylib', + '/App.app/Contents/Frameworks/Electron.framework', + '/App.app', + ]) expect(ignored(path)).toBe(false) + }) + + it('copies the complete runtime despite electron-builder excluding root node_modules', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') + const root = mkdtempSync(join(tmpdir(), 'desktop-resource-copy-')) + try { + const source = join(root, 'source') + const destination = join(root, 'resources') + runtimeFixture(source) + const sourceRoot = config.extraResources[1].from + const matchers = config.extraResources.slice(1).map(entry => new FileMatcher( + join(source, relative(sourceRoot, entry.from)), join(destination, entry.to), value => value, + )) + await copyFiles(matchers.slice(0, 1)) + await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).rejects.toThrow(/ENOENT/u) + rmSync(destination, { recursive: true }) + await copyFiles(matchers) + await expect(verifyDesktopRuntime(join(destination, 'dsh'), '1.0.0')).resolves.toMatchObject({ release: { version: '1.0.0' } }) + } finally { rmSync(root, { recursive: true, force: true }) } + }) + it('validates Windows signing without requiring macOS identifiers for a Windows target', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') expect(() => createElectronBuilderConfig({ @@ -72,6 +120,29 @@ describe('desktop macOS release signature', () => { }, 'win32')).toThrow(/DSH_DESKTOP_WINDOWS_CER_FILE/u) }) + it('isolates unsigned Windows artifacts and omits updater metadata without release credentials', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: RELEASE_ENVIRONMENT.DSH_DESKTOP_APP_ID, + DSH_DESKTOP_TARGET_PLATFORM: 'win32', + DSH_DESKTOP_UNSIGNED: '1', + }, 'win32', 'x64') + expect(portablePath(config.directories.output)).toContain('/targets/win-x64/unsigned-artifacts') + expect(portablePath(config.nsis.include)).toMatch(/\/scripts\/installer\.nsh$/u) + expect(config).toMatchObject({ + win: { forceCodeSigning: false, signtoolOptions: { sign: undefined } }, + publish: null, + }) + }) + + it('rejects unsigned macOS builds and malformed signing modes', async () => { + const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: '1' })) + .toThrow(/unsigned builds require Windows/u) + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_UNSIGNED: 'yes' })) + .toThrow(/must be 0 or 1/u) + }) + it('accepts the configured authority and team', () => { const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) expect(() => { @@ -82,7 +153,7 @@ describe('desktop macOS release signature', () => { }).not.toThrow() }) - it('requires a secure timestamp and hardened runtime for seed code', () => { + it('requires a secure timestamp and hardened runtime for runtime code', () => { const expected = resolveMacOSSigningEnvironment(RELEASE_ENVIRONMENT) const details = [ `Authority=Developer ID Application: ${expected.signingIdentity}`, @@ -90,12 +161,12 @@ describe('desktop macOS release signature', () => { 'Timestamp=31 Aug 2026 at 20:00:00', 'CodeDirectory v=20500 size=773 flags=0x10000(runtime) hashes=13+7 location=embedded', ].join('\n') - expect(() => { assertMacOSSeedSignatureDetails(details, expected) }).not.toThrow() + expect(() => { assertMacOSRuntimeSignatureDetails(details, expected) }).not.toThrow() expect(() => { - assertMacOSSeedSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) + assertMacOSRuntimeSignatureDetails(details.replace(/^Timestamp=.*\n/um, ''), expected) }).toThrow(/secure timestamp/u) expect(() => { - assertMacOSSeedSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) + assertMacOSRuntimeSignatureDetails(details.replace('flags=0x10000(runtime)', 'flags=0x0(none)'), expected) }).toThrow(/hardened runtime/u) }) diff --git a/apps/desktop/tests/macos-signing-walk.spec.ts b/apps/desktop/tests/macos-signing-walk.spec.ts new file mode 100644 index 0000000000..8dccc4645b --- /dev/null +++ b/apps/desktop/tests/macos-signing-walk.spec.ts @@ -0,0 +1,36 @@ +/** Exercise the installed macOS signer against Framework file and directory aliases. */ + +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' + +const builderRequire = createRequire(createRequire(import.meta.url).resolve('app-builder-lib')) +const { walkAsync } = builderRequire(join(dirname(builderRequire.resolve('@electron/osx-sign')), 'util.js')) as { + walkAsync: (path: string) => Promise +} + +// macOS Framework aliases use POSIX file and directory symlinks, unavailable on unprivileged Windows runners. +it.skipIf(process.platform === 'win32')('signs each real Framework file once and retains nested bundle signing', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-signing-walk-')) + try { + const contents = join(root, 'Contents') + const framework = join(contents, 'Frameworks', 'Test.framework') + const version = join(framework, 'Versions', 'A') + const resource = join(version, 'Resources', 'en.lproj', 'locale.pak') + const library = join(version, 'library.dylib') + const helper = join(contents, 'Frameworks', 'Helper.app') + const executable = join(helper, 'Contents', 'MacOS', 'helper') + for (const file of [resource, library, executable]) { + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0, 0, 0, 0])) + } + symlinkSync('A', join(framework, 'Versions', 'Current')) + symlinkSync('Versions/Current/Resources', join(framework, 'Resources')) + symlinkSync('Versions/Current/library.dylib', join(framework, 'library.dylib')) + const files = await walkAsync(contents) + expect(files.sort()).toEqual([resource, library, executable, framework, helper].sort()) + expect(new Set(files.map(file => realpathSync(file))).size).toBe(files.length) + } finally { rmSync(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts new file mode 100644 index 0000000000..2b2e47d66d --- /dev/null +++ b/apps/desktop/tests/main-startup.spec.ts @@ -0,0 +1,368 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const harness = await vi.hoisted(async () => { + const { EventEmitter } = await import('node:events') + function deferred() { + let resolve!: () => void + let reject!: (error: Error) => void + const promise = new Promise((accept, decline) => { resolve = accept; reject = decline }) + return { promise, resolve, reject } + } + const windows: FakeWindow[] = [] + const hosts: FakeHost[] = [] + const handlers = new Map unknown>() + let pluginsEnabled = false + let preparing = deferred() + let prepared = deferred() + let hostStarted = deferred() + let navigated = deferred() + let errorPublished = deferred() + let quitCompleted = deferred() + class FakeWindow extends EventEmitter { + destroyed = false + readonly urls: string[] = [] + readonly webContents = Object.assign(new EventEmitter(), { + setWindowOpenHandler: vi.fn(), + openDevTools: vi.fn(), + getURL: () => this.urls.at(-1) ?? '', + send: vi.fn((channel: string, state: { phase?: string }) => { + if (channel === 'dsh-desktop:backend-state' && state.phase === 'error') errorPublished.resolve() + }), + }) + readonly show = vi.fn() + readonly focus = vi.fn() + readonly restore = vi.fn() + constructor(readonly options: { show: boolean }) { super(); windows.push(this) } + isDestroyed() { return this.destroyed } + isMinimized() { return false } + async loadURL(url: string) { + this.urls.push(url) + if (url === 'dsh-app://app/index.html') navigated.resolve() + } + static getAllWindows() { return windows.filter(window => !window.destroyed) } + close() { this.destroyed = true; this.emit('closed') } + } + class FakeHost { + readonly ready = deferred() + readonly exited = deferred() + readonly stopping = deferred() + readonly start = vi.fn(() => { hostStarted.resolve(); return this.ready.promise }) + readonly stop = vi.fn(() => { + this.stopping.resolve() + this.ready.reject(new Error('child stopped')) + return this.exited.promise + }) + constructor(readonly node: string, readonly runtime: string, readonly profile: string) { hosts.push(this) } + } + const app = Object.assign(new EventEmitter(), { + isPackaged: true, + name: 'Desktop test', + whenReady: () => Promise.resolve(), + getLocale: () => 'en-US', + getVersion: () => '1.0.0', + getAppPath: () => 'desktop-test-app', + requestSingleInstanceLock: () => true, + exit: vi.fn(), + relaunch: vi.fn(), + quit: vi.fn(() => { + const event = { preventDefault: vi.fn() } + app.emit('before-quit', event) + if (event.preventDefault.mock.calls.length === 0) quitCompleted.resolve() + }), + }) + return { + windows, hosts, handlers, app, FakeWindow, FakeHost, + dialog: { showErrorBox: vi.fn(), showMessageBox: vi.fn() }, + applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }), + assertProfileRuntime: vi.fn(), + canRecoverProfile: vi.fn(() => true), + get preparing() { return preparing }, get prepared() { return prepared }, + get hostStarted() { return hostStarted }, get navigated() { return navigated }, + get errorPublished() { return errorPublished }, get quitCompleted() { return quitCompleted }, + nextHostStart() { hostStarted = deferred(); return hostStarted.promise }, + get pluginsEnabled() { return pluginsEnabled }, + set pluginsEnabled(value: boolean) { pluginsEnabled = value }, + reset() { + windows.length = 0; hosts.length = 0; handlers.clear(); app.removeAllListeners() + app.isPackaged = true + pluginsEnabled = false + preparing = deferred(); prepared = deferred(); hostStarted = deferred() + navigated = deferred(); errorPublished = deferred(); quitCompleted = deferred() + }, + } +}) + +vi.mock('electron', () => ({ + app: harness.app, + BrowserWindow: harness.FakeWindow, + dialog: harness.dialog, + ipcMain: { + handle: (channel: string, handler: (event: { senderFrame: { url: string } }) => unknown) => { harness.handlers.set(channel, handler) }, + }, + Menu: { setApplicationMenu: vi.fn(), buildFromTemplate: vi.fn() }, + protocol: { registerSchemesAsPrivileged: vi.fn(), handle: vi.fn() }, +})) +vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: 'desktop-test-profile' }) })) +vi.mock('../src/project-manager.ts', () => ({ + DesktopProjectManager: class { + readonly applyRelease = harness.applyRelease + readonly assertProfileRuntime = harness.assertProfileRuntime + canRecoverProfile = harness.canRecoverProfile + async mutate(_mutation: unknown, hooks: { beforeChange(): Promise; afterChange(): Promise }) { + await hooks.beforeChange() + harness.pluginsEnabled = false + await hooks.afterChange() + } + async resetConfiguration(hooks: { beforeChange(): Promise; afterChange(): Promise }) { + await this.mutate(undefined, hooks) + } + }, +})) +vi.mock('../src/host-process.ts', () => ({ DesktopHostProcess: harness.FakeHost })) +vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: vi.fn() })) + +function invoke(channel: string): unknown { + const handler = harness.handlers.get(channel) + if (handler === undefined) throw new Error(`missing handler ${channel}`) + return handler({ senderFrame: { url: 'dsh-app://shell/startup.html' } }) +} + +beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.useFakeTimers() + harness.reset() + vi.stubEnv('DSH_DESKTOP_NODE_BINARY', 'test-node') + vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', 'test-pnpm') + vi.stubEnv('DSH_DESKTOP_DSH_DIR', 'test-runtime') + vi.stubGlobal('process', { ...process, resourcesPath: 'desktop-test-resources' }) + vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) +}) + +afterEach(async () => { + harness.prepared.resolve() + for (const host of harness.hosts) { host.ready.resolve(); host.exited.resolve() } + harness.app.quit() + await harness.quitCompleted.promise + vi.restoreAllMocks() + harness.canRecoverProfile.mockReturnValue(true) + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('desktop main startup', () => { + it('exits with a diagnostic when both initialization and emergency navigation fail', async () => { + const exited = Promise.withResolvers() + vi.spyOn(harness.app, 'getLocale').mockImplementationOnce(() => { throw new Error('locale unavailable') }) + vi.spyOn(harness.FakeWindow.prototype, 'loadURL').mockRejectedValueOnce(new Error('emergency navigation failed')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + harness.app.exit.mockImplementationOnce(() => { exited.resolve(undefined) }) + await import('../src/main.ts') + await exited.promise + expect(harness.app.exit).toHaveBeenCalledWith(1) + expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'emergency navigation failed' })) + }) + + it('withholds profile recovery after application resources fail to load', async () => { + harness.canRecoverProfile.mockReturnValue(false) + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.reject(new Error('runtime resources missing')) + await harness.errorPublished.promise + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: false }) + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + const html = decodeURIComponent(window.urls.at(-1)!) + expect(html).toContain('dsh-recovery://restart') + expect(html).not.toContain('dsh-recovery://reset') + expect(html).not.toContain('dsh-recovery://plugins') + }) + + it('reloads a crashed startup renderer in the same window', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('render-process-gone', {}, { reason: 'crashed' }) + await harness.errorPublished.promise + expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://shell/startup.html']) + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', message: 'Desktop renderer exited: crashed' }) + }) + + it.each(['plugins', 'reset'])('runs %s recovery from a document with a broken preload', async (action) => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + const started = harness.nextHostStart() + const event = { preventDefault: vi.fn() } + window.webContents.emit('will-navigate', event, `dsh-recovery://${action}/?`) + await harness.hosts[0]!.stopping.promise + harness.hosts[0]!.exited.resolve() + await started + harness.hosts[1]!.ready.resolve() + await harness.navigated.promise + expect(event.preventDefault).toHaveBeenCalled() + expect(window.urls.at(-1)).toBe('dsh-app://app/index.html') + }) + + it('allows a full profile reset for an unclassified startup failure', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.exited.resolve() + harness.hosts[0]!.ready.reject(new Error('Unknown startup failure')) + await harness.errorPublished.promise + const started = harness.nextHostStart() + const reset = Promise.resolve(invoke(DESKTOP_IPC.configurationReset)) + await started + harness.hosts[1]!.ready.resolve() + await reset + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('keeps a self-contained reinstall document in the main window after preload failure', async () => { + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.webContents.emit('preload-error', {}, 'preload-app.cjs', new Error('preload unavailable')) + expect(window.urls.at(-1)).toContain('data:text/html') + expect(decodeURIComponent(window.urls.at(-1)!)).toContain('preload unavailable') + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + expect(harness.windows).toHaveLength(1) + expect(window.urls.at(-1)).toContain('data:text/html') + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('offers plugin recovery and disables plugins before restarting in the same window', async () => { + harness.pluginsEnabled = true + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.exited.resolve() + harness.hosts[0]!.ready.reject(new Error('Plugin initialization failed')) + await harness.errorPublished.promise + expect(invoke(DESKTOP_IPC.backendStatus)).toMatchObject({ phase: 'error', profileRecovery: true }) + const nextStarted = harness.nextHostStart() + const recovery = Promise.resolve(invoke(DESKTOP_IPC.pluginsDisableAll)) + await nextStarted + expect(harness.pluginsEnabled).toBe(false) + harness.hosts[1]!.ready.resolve() + await recovery + expect(harness.windows).toHaveLength(1) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('waits for Host exit before relaunching the application', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + const restart = Promise.resolve(invoke(DESKTOP_IPC.applicationRestart)) + await harness.hosts[0]!.stopping.promise + expect(harness.app.relaunch).not.toHaveBeenCalled() + harness.hosts[0]!.exited.resolve() + await restart + expect(harness.app.relaunch).toHaveBeenCalledOnce() + }) + + it('shows the loading window before profile preparation and starts one actual Host', async () => { + await import('../src/main.ts') + await harness.preparing.promise + expect(harness.windows).toHaveLength(1) + const window = harness.windows[0]! + expect(window.options.show).toBe(true) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + expect(harness.hosts).toHaveLength(0) + const retry = invoke(DESKTOP_IPC.backendRetry) + const secondRetry = invoke(DESKTOP_IPC.backendRetry) + harness.prepared.resolve() + await harness.hostStarted.promise + expect(harness.hosts).toHaveLength(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + harness.hosts[0]!.ready.resolve() + await Promise.all([retry, secondRetry, harness.navigated.promise]) + expect(harness.applyRelease).toHaveBeenCalledTimes(1) + expect(harness.assertProfileRuntime).toHaveBeenCalledWith('desktop-test-profile') + expect(harness.hosts[0]).toMatchObject({ + node: join('desktop-test-resources', 'runtime', 'node', process.platform === 'win32' ? 'node.exe' : 'node'), + runtime: join('desktop-test-resources', 'dsh'), + profile: 'desktop-test-profile', + }) + expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1) + expect(harness.windows).toHaveLength(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html', 'dsh-app://app/index.html']) + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'ready' }) + }) + + it('starts the unpackaged Host from the application development directory', async () => { + harness.app.isPackaged = false + await import('../src/main.ts') + await harness.hostStarted.promise + const project = join(harness.app.getAppPath(), '.desktop-build', 'development', 'project') + expect(harness.hosts[0]).toMatchObject({ node: 'test-node', runtime: project, profile: project }) + expect(harness.applyRelease).not.toHaveBeenCalled() + expect(harness.assertProfileRuntime).not.toHaveBeenCalled() + harness.hosts[0]!.ready.resolve() + await harness.navigated.promise + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('keeps startup errors and a successful retry in the same window', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const first = harness.hosts[0]! + const failedRetry = expect(Promise.resolve(invoke(DESKTOP_IPC.backendRetry))).rejects.toThrow('plugin composition failed') + first.exited.resolve() + first.ready.reject(new Error('plugin composition failed')) + await harness.errorPublished.promise + await failedRetry + expect(invoke(DESKTOP_IPC.backendStatus)).toEqual({ phase: 'error', message: 'plugin composition failed', profileRecovery: true }) + expect(harness.windows[0]!.urls).toEqual(['dsh-app://shell/startup.html']) + const nextStarted = harness.nextHostStart() + const retry = Promise.resolve(invoke(DESKTOP_IPC.backendRetry)) + await nextStarted + expect(harness.hosts).toHaveLength(2) + harness.hosts[1]!.ready.resolve() + await retry + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.urls.at(-1)).toBe('dsh-app://app/index.html') + expect(harness.dialog.showErrorBox).not.toHaveBeenCalled() + }) + + it('waits for a pending child to exit on quit without late window navigation', async () => { + await import('../src/main.ts') + await harness.preparing.promise + harness.prepared.resolve() + await harness.hostStarted.promise + const window = harness.windows[0]! + const host = harness.hosts[0]! + host.stop.mockImplementation(() => { host.stopping.resolve(); return host.exited.promise }) + window.close() + harness.app.quit() + await host.stopping.promise + expect(harness.app.quit).toHaveBeenCalledTimes(1) + host.ready.resolve() + host.exited.resolve() + await harness.quitCompleted.promise + expect(host.stop).toHaveBeenCalledTimes(1) + expect(window.urls).toEqual(['dsh-app://shell/startup.html']) + expect(harness.windows).toHaveLength(1) + }) +}) diff --git a/apps/desktop/tests/owned-directory.spec.ts b/apps/desktop/tests/owned-directory.spec.ts new file mode 100644 index 0000000000..081e5adc76 --- /dev/null +++ b/apps/desktop/tests/owned-directory.spec.ts @@ -0,0 +1,53 @@ +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { removeOwnedDirectory } from '../src/owned-directory.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-owned-directory-')) + roots.push(root) + return root +} + +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('removes nested files and directories while preserving unknown directory-link targets', () => { + const root = fixture() + const target = join(root, 'target') + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + const owned = join(root, 'owned') + const nested = join(owned, 'node_modules', '@unknown', 'private') + mkdirSync(nested, { recursive: true }) + writeFileSync(join(nested, 'file'), 'remove') + symlinkSync(target, join(nested, 'link'), process.platform === 'win32' ? 'junction' : 'dir') + symlinkSync(join(root, 'missing-target'), join(owned, 'broken'), process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(owned) + expect(existsSync(owned)).toBe(false) + expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it.each([false, true])('unlinks a root directory link with missing target: %s', (missing) => { + const root = fixture() + const target = join(root, 'target') + if (!missing) { + mkdirSync(target) + writeFileSync(join(target, 'sentinel'), 'preserved') + } + const link = join(root, 'link') + symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir') + removeOwnedDirectory(link) + expect(lstatSync(link, { throwIfNoEntry: false })).toBeUndefined() + if (!missing) expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('preserved') +}) + +it('ignores a missing root and refuses to delete a regular-file root', () => { + const root = fixture() + expect(() => { removeOwnedDirectory(join(root, 'missing')) }).not.toThrow() + const file = join(root, 'file') + writeFileSync(file, 'preserved') + expect(() => { removeOwnedDirectory(file) }).toThrow(/not a directory/u) + expect(readFileSync(file, 'utf8')).toBe('preserved') +}) diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts new file mode 100644 index 0000000000..fcbc743bbd --- /dev/null +++ b/apps/desktop/tests/package-macos.spec.ts @@ -0,0 +1,190 @@ +/** Exercise notarization overlap and artifact isolation without Apple credentials or network. */ + +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + packageMacOSArtifacts, + type DesktopPrepackagedArtifact, + type MacOSArtifactOperations, +} from '../scripts/package-macos.ts' +import { desktopElectronBuilderArguments, resolveDesktopPackageTarget } from '../scripts/package-target.ts' + +const environment = { + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_KEYCHAIN_PROFILE: 'fixture-profile', +} + +function barrier() { + let release!: () => void + const promise = new Promise((resolve) => { release = resolve }) + return { promise, release } +} + +async function fixture(arch: 'arm64' | 'x64' = 'arm64') { + const root = await mkdtemp(join(tmpdir(), 'desktop-parallel-notarization-')) + const artifactsRoot = join(root, 'artifacts') + const appPath = join(artifactsRoot, arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') + await mkdir(appPath, { recursive: true }) + await writeFile(join(appPath, 'payload'), 'signed content') + const version = '1.2.3-alpha.1' + const base = `deepseek-harness-${version}-mac-${arch}` + const request = { arch, artifactsRoot, version, environment } + const apple: MacOSArtifactOperations = { + copyApp: async (source, destination) => { + await cp(source, destination, { recursive: true, verbatimSymlinks: true }) + }, + notarize: async ({ appPath: path }) => { await writeFile(join(path, 'ticket'), 'accepted') }, + verifySignature: vi.fn(), + verifyNotarization: vi.fn((path: string) => { + if (!existsSync(join(path, 'ticket'))) throw new Error('missing App ticket') + }), + } + const build = async (artifact: DesktopPrepackagedArtifact) => { + await mkdir(artifact.output, { recursive: true }) + const contents = JSON.stringify({ + payload: await readFile(join(artifact.appPath, 'payload'), 'utf8'), + appTicket: existsSync(join(artifact.appPath, 'ticket')), + }) + await writeFile(join(artifact.output, `${base}.${artifact.format}`), contents) + if (artifact.format === 'zip') { + await writeFile(join(artifact.output, `${base}.zip.blockmap`), 'blockmap') + await writeFile(join(artifact.output, 'alpha-mac.yml'), 'update metadata') + } + } + return { root, appPath, request, apple, build, base } +} + +describe('parallel macOS artifacts', () => { + it.each(['arm64', 'x64'] as const)('overlaps notarization on isolated %s copies and promotes only completed payloads', async (arch) => { + const f = await fixture(arch) + const appStarted = barrier() + const appAccepted = barrier() + const dmgCompleted = barrier() + const zipCompleted = barrier() + const starts: string[] = [] + const copies: string[] = [] + const operation = packageMacOSArtifacts(f.request, async (artifact) => { + starts.push(artifact.format) + if (artifact.format === 'dmg') await dmgCompleted.promise + await f.build(artifact) + if (artifact.format === 'zip') zipCompleted.release() + }, { + ...f.apple, + copyApp: async (source, destination) => { + copies.push(destination) + await f.apple.copyApp(source, destination) + }, + notarize: async (options) => { + starts.push('app') + appStarted.release() + await appAccepted.promise + await f.apple.notarize(options) + }, + }) + try { + await appStarted.promise + await vi.waitFor(() => { expect([...starts]).toEqual(expect.arrayContaining(['app', 'dmg'])) }) + expect(new Set(copies).size).toBe(2) + expect(copies.every(path => path !== f.appPath)).toBe(true) + appAccepted.release() + await zipCompleted.promise + expect(existsSync(join(f.appPath, 'ticket'))).toBe(false) + expect(existsSync(join(f.request.artifactsRoot, `${f.base}.zip`))).toBe(false) + dmgCompleted.release() + await operation + expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.zip`), 'utf8'))) + .toEqual({ payload: 'signed content', appTicket: true }) + expect(JSON.parse(await readFile(join(f.request.artifactsRoot, `${f.base}.dmg`), 'utf8'))) + .toEqual({ payload: 'signed content', appTicket: false }) + expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted') + expect((await readdir(f.root)).sort()).toEqual(['artifacts']) + expect(f.apple.verifySignature).toHaveBeenCalledTimes(2) + expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1) + } finally { + appAccepted.release() + dmgCompleted.release() + await Promise.allSettled([operation]) + await rm(f.root, { recursive: true, force: true }) + } + }) + + it('collects both failures after both lanes release their copies and publishes neither payload', async () => { + const f = await fixture() + const appStarted = barrier() + const failApp = barrier() + const failDmg = barrier() + const appError = new Error('App rejected') + const dmgError = new Error('DMG rejected') + const released: string[] = [] + const outcome = packageMacOSArtifacts(f.request, async (artifact) => { + expect(artifact.format).toBe('dmg') + await failDmg.promise + expect(await readFile(join(artifact.appPath, 'payload'), 'utf8')).toBe('signed content') + released.push('dmg') + throw dmgError + }, { + ...f.apple, + notarize: async () => { + appStarted.release() + await failApp.promise + released.push('app') + throw appError + }, + }).catch((error: unknown) => error) + try { + await appStarted.promise + failApp.release() + failDmg.release() + const error = await outcome + expect(error).toBeInstanceOf(AggregateError) + expect((error as AggregateError).errors).toEqual([appError, dmgError]) + expect(released.sort()).toEqual(['app', 'dmg']) + expect(await readdir(f.root)).toEqual(['artifacts']) + expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64']) + expect(existsSync(join(f.appPath, 'ticket'))).toBe(false) + } finally { + failApp.release() + failDmg.release() + await outcome + await rm(f.root, { recursive: true, force: true }) + } + }) + + it.each(['copy', 'signature', 'ticket', 'metadata'] as const)('rejects incomplete %s qualification without promoting artifacts', async (failure) => { + const f = await fixture() + try { + const apple: MacOSArtifactOperations = { + ...f.apple, + ...(failure === 'copy' ? { copyApp: async () => { throw new Error('copy failed') } } : {}), + ...(failure === 'signature' ? { verifySignature: () => { throw new Error('signature failed') } } : {}), + ...(failure === 'ticket' ? { verifyNotarization: () => { throw new Error('ticket failed') } } : {}), + } + await expect(packageMacOSArtifacts(f.request, async (artifact) => { + await f.build(artifact) + if (failure === 'metadata' && artifact.format === 'zip') { + await writeFile(join(artifact.output, 'alpha-mac.yml'), '') + } + }, apple)).rejects.toThrow() + expect(await readdir(f.root)).toEqual(['artifacts']) + expect(await readdir(f.request.artifactsRoot)).toEqual(['mac-arm64']) + } finally { await rm(f.root, { recursive: true, force: true }) } + }) + + it('passes the actual App and isolated output directory to each single-target builder', () => { + const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64') + for (const format of ['zip', 'dmg'] as const) { + const appPath = join('private build', format, 'DeepSeek Harness.app') + const output = join(dirname(appPath), 'artifacts') + expect(desktopElectronBuilderArguments(target, false, { format, appPath, output })).toEqual([ + 'exec', 'electron-builder', '--config', 'electron-builder.config.mjs', + '--mac', format, '--arm64', '--publish', 'never', + '--config.mac.notarize=false', + '--prepackaged', appPath, '--config.directories.output', output, + ]) + } + }) +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index e822f212f5..d284747e86 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { desktopElectronBuilderArguments, + desktopElectronBuilderEnvironment, parseDesktopPackageInvocation, resolveDesktopPackageTarget, withoutDesktopUploadCredentials, @@ -56,7 +57,46 @@ describe('desktop package target', () => { expect(desktopElectronBuilderArguments(target, true)).toContain('--dir') }) - it('keeps Windows signing fields out of build and seed preparation subprocesses', () => { + it('accepts unsigned Windows artifacts and rejects other targets or preparation-only use', () => { + expect(parseDesktopPackageInvocation(['win-x64', '--unsigned'], 'win32', 'x64').unsigned).toBe(true) + expect(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64').unsigned).toBe(false) + expect(parseDesktopPackageInvocation(['--unsigned', '--dir'], 'win32', 'x64')).toMatchObject({ + unsigned: true, directory: true, + }) + expect(() => parseDesktopPackageInvocation(['mac-arm64', '--unsigned'], 'darwin', 'arm64')) + .toThrow(/requires win-x64/u) + expect(() => parseDesktopPackageInvocation(['--unsigned', '--prepare-only'], 'win32', 'x64')) + .toThrow(/cannot use --prepare-only/u) + }) + + it('removes ambient certificate inputs for unsigned builds and overrides an inherited signing mode', () => { + const environment = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', + CSC_LINK: 'private.pfx', + CSC_KEY_PASSWORD: 'secret', + WIN_CSC_LINK: 'windows.pfx', + CSC_IDENTITY_AUTO_DISCOVERY: 'true', + DSH_DESKTOP_UNSIGNED: '1', + } + expect(desktopElectronBuilderEnvironment(environment, true)).toEqual({ + DSH_DESKTOP_APP_ID: 'com.example.desktop', + CSC_IDENTITY_AUTO_DISCOVERY: 'false', + DSH_DESKTOP_UNSIGNED: '1', + }) + expect(desktopElectronBuilderEnvironment(environment, false)).toEqual({ ...environment, DSH_DESKTOP_UNSIGNED: '0' }) + }) + + it.each([false, true])('pins the Windows archive filter for the NSIS decoder (unsigned: %s)', (unsigned) => { + expect(desktopElectronBuilderEnvironment({ + DSH_DESKTOP_TARGET_PLATFORM: 'win32', ELECTRON_BUILDER_7Z_FILTER: 'ARM64', + }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('BCJ') + expect(desktopElectronBuilderEnvironment({ + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', ELECTRON_BUILDER_7Z_FILTER: 'ARM', + }, unsigned).ELECTRON_BUILDER_7Z_FILTER).toBe('ARM') + }) + + it('keeps Windows signing fields out of build and runtime preparation subprocesses', () => { expect(withoutWindowsSigningEnvironment({ DSH_DESKTOP_WINDOWS_CER_FILE: 'C:\\release\\server.cer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'token-secret', diff --git a/apps/desktop/tests/plugin-manager.spec.ts b/apps/desktop/tests/plugin-manager.spec.ts new file mode 100644 index 0000000000..d870b7ecc7 --- /dev/null +++ b/apps/desktop/tests/plugin-manager.spec.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { JSDOM } from 'jsdom' +import { expect, it, vi } from 'vitest' +import { resolveDesktopLocale } from '../src/locale.ts' + +it('keeps disabled packages visible and offers recovery without a running backend', async () => { + const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + let enabled = true + let ready = false + const disableAll = vi.fn(async () => { enabled = false; ready = true }) + const toggle = vi.fn(async (_name: string, active: boolean) => { enabled = active }) + const api = { + locale: async () => resolveDesktopLocale('en'), + backend: { status: async () => ready ? { phase: 'ready' } : { phase: 'error', message: 'plugin requires Cordis ^2.0.0' }, retry: vi.fn() }, + plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled }], disableAll, toggle }, + } + Object.defineProperty(dom.window, 'dshDesktop', { value: api }) + try { + runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + const document = dom.window.document + await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin') + expect(document.querySelector('#recovery')?.hidden).toBe(false) + expect(document.querySelector('#startup-error')?.textContent).toBe('plugin requires Cordis ^2.0.0') + document.querySelector('#disable-all')?.click() + await expect.poll(() => document.querySelector('#recovery')?.hidden).toBe(true) + expect(disableAll).toHaveBeenCalledOnce() + expect(document.querySelector('#plugins li')?.textContent).toMatchInlineSnapshot('"example-plugin1.0.0 · DisabledEnableUpdateRemove"') + document.querySelector('#plugins li button')?.click() + await expect.poll(() => toggle.mock.calls).toEqual([['example-plugin', true]]) + await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove') + } finally { dom.window.close() } +}) diff --git a/apps/desktop/tests/plugin-pnpm.spec.ts b/apps/desktop/tests/plugin-pnpm.spec.ts new file mode 100644 index 0000000000..351387160e --- /dev/null +++ b/apps/desktop/tests/plugin-pnpm.spec.ts @@ -0,0 +1,72 @@ +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { c } from 'tar' +import { expect, it } from 'vitest' +import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts' +import { resolveDesktopPaths } from '../src/paths.ts' +import { runtimeFixture, writePackage } from './runtime-fixture.ts' + +it('installs a real pnpm graph, then executes approved scripts with the shared host instance', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-real-pnpm-'))) + const server = createServer() + const archives = new Map() + try { + for (const name of ['fixture-plugin', 'node-pty']) { + const path = writePackage(join(root, 'packages'), name, name === 'fixture-plugin' + ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dsh: { bundle: { patch: 'bundle.yml' } } } + : { scripts: { install: 'node install.cjs' } }, 'export {identity} from "@deepseek-ai/cordis"') + writeFileSync(join(path, 'bundle.yml'), '[]\n') + writeFileSync(join(path, 'install.cjs'), 'require("node:fs").writeFileSync("built.json", JSON.stringify({node:process.execPath, host:require.resolve("@deepseek-ai/cordis")}))') + const tarball = join(root, `${name}.tgz`) + await c({ file: tarball, cwd: join(path, '..'), gzip: true }, [name]) + archives.set(name, readFileSync(tarball)) + } + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('fixture registry has no TCP address') + const origin = `http://127.0.0.1:${address.port}` + server.on('request', (request, response) => { + const name = request.url?.slice(1).replace(/\.tgz$/u, '') ?? '' + const archive = archives.get(name) + if (archive === undefined) { response.writeHead(404); response.end(); return } + if (request.url?.endsWith('.tgz')) { response.end(archive); return } + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify({ name, 'dist-tags': { latest: '1.0.0' }, versions: { '1.0.0': { + name, version: '1.0.0', dist: { tarball: `${origin}/${name}.tgz`, integrity: `sha512-${createHash('sha512').update(archive).digest('base64')}` }, + ...(name === 'fixture-plugin' ? { dependencies: { 'node-pty': '1.0.0' }, peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' } } : {}), + } }, time: { '1.0.0': '2020-01-01T00:00:00.000Z' } })) + }) + const dsh = join(root, 'dsh') + runtimeFixture(dsh) + const pnpm = join(root, 'pnpm.mjs') + const realPnpm = join(import.meta.dirname, '../node_modules/pnpm/bin/pnpm.mjs') + writeFileSync(pnpm, `process.argv = process.argv.map(arg => arg === '--config.registry=https://registry.npmjs.org/' ? ${JSON.stringify(`--config.registry=${origin}`)} : arg); await import(${JSON.stringify(pathToFileURL(realPnpm).href)})`) + const manager = new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm, dsh }) + const hooks: DesktopProjectHooks = { beforeChange: async () => {}, afterChange: async () => {} } + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'fixture-plugin@1.0.0' }, hooks) + expect(manager.listPlugins()).toEqual([{ name: 'fixture-plugin', version: '1.0.0', enabled: true }]) + const built = JSON.parse(readFileSync(join(manager.paths.profile, 'node_modules/node-pty/built.json'), 'utf8')) as { node: string; host: string } + expect(realpathSync(built.node)).toBe(realpathSync(process.execPath)) + expect(built.host).toBe(join(dsh, 'node_modules/@deepseek-ai/cordis/index.js')) + const entry = join(dsh, 'identity.mjs') + writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import {identity as plugin} from ${JSON.stringify(pathToFileURL(join(manager.paths.profile, 'node_modules/fixture-plugin/index.js')).href)}; console.log(identity === plugin)`) + expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('true') + await manager.mutate({ type: 'plugin-remove', name: 'fixture-plugin' }, hooks) + expect(manager.listPlugins()).toEqual([]) + } finally { + server.closeAllConnections() + if (server.listening) await new Promise((resolve, reject) => { + server.close((error) => { + if (error !== undefined) reject(error) + else resolve() + }) + }) + rmSync(root, { recursive: true, force: true }) + } +}, 30_000) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts new file mode 100644 index 0000000000..64047c0c0c --- /dev/null +++ b/apps/desktop/tests/preload-app.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { DESKTOP_IPC, type DshDesktopStartupApi } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn() }, +})) +vi.mock('electron', () => electron) + +afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) + +it.each(['dsh-app://app/index.html', 'https://shell/startup.html'])('exposes only the carrier marker to %s', async (url) => { + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + +it('provides startup controls and a removable state subscription to shell documents', async () => { + vi.stubGlobal('location', new URL('dsh-app://shell/startup.html')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]?.[1] as DshDesktopStartupApi + await api.locale() + await api.backend.status() + await api.disablePlugins() + await api.resetConfiguration() + await api.restart() + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.localeGet], [DESKTOP_IPC.backendStatus], + [DESKTOP_IPC.pluginsDisableAll], [DESKTOP_IPC.configurationReset], [DESKTOP_IPC.applicationRestart], + ]) + const listener = vi.fn() + const dispose = api.backend.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void + handler({}, { phase: 'error', message: 'startup failed' }) + expect(listener).toHaveBeenCalledWith({ phase: 'error', message: 'startup failed' }) + dispose() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.backendState, handler) + expect(api).not.toHaveProperty('plugins') +}) diff --git a/apps/desktop/tests/profile-packages.spec.ts b/apps/desktop/tests/profile-packages.spec.ts new file mode 100644 index 0000000000..d419c962f2 --- /dev/null +++ b/apps/desktop/tests/profile-packages.spec.ts @@ -0,0 +1,78 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, expect, it } from 'vitest' +import { createPluginProfile } from '../src/project-manager.ts' +import { linkDesktopHostPackages, unlinkDesktopHostPackages, validateDesktopPluginGraph } from '../src/profile-packages.ts' +import { runtimeFixture, writePackage } from './runtime-fixture.ts' + +const roots: string[] = [] +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'desktop-profile-')) + roots.push(root) + const dsh = join(root, 'dsh') + const runtime = runtimeFixture(dsh) + const profile = join(root, 'profile') + createPluginProfile(profile) + linkDesktopHostPackages(profile, dsh, runtime) + return { root, dsh, runtime, profile } +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('loads one shared ESM instance from both host and external plugin while keeping ordinary dependencies private', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(dsh, 'node_modules'), 'ordinary', {}, 'export default "host"') + writePackage(join(profile, 'node_modules'), 'ordinary', {}, 'export default "plugin"') + const plugin = writePackage(join(profile, 'node_modules'), 'plugin', { + peerDependencies: { '@deepseek-ai/cordis': '^1.0.0' }, dependencies: { ordinary: '1.0.0' }, + }, 'export { identity } from "@deepseek-ai/cordis"; export { default as ordinary } from "ordinary"') + validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) + const entry = join(dsh, 'check.mjs') + writeFileSync(entry, `import {identity} from '@deepseek-ai/cordis'; import ordinary from 'ordinary'; import * as plugin from ${JSON.stringify(pathToFileURL(join(plugin, 'index.js')).href)}; console.log(JSON.stringify({same:identity===plugin.identity, host:ordinary, plugin:plugin.ordinary}))`) + const output = execFileSync(process.execPath, [entry], { encoding: 'utf8', env: { ...process.env, NODE_OPTIONS: '', NODE_PATH: '' } }) + expect(JSON.parse(output)).toEqual({ same: true, host: 'host', plugin: 'plugin' }) +}) +it.each(['nested', 'alias'])('rejects a %s second copy of a host package', (placement) => { + const { dsh, runtime, profile } = fixture() + const plugin = writePackage(join(profile, 'node_modules'), 'plugin') + if (placement === 'nested') writePackage(join(plugin, 'node_modules'), '@deepseek-ai/cordis') + else writePackage(join(profile, 'node_modules'), 'alias', { name: '@deepseek-ai/cordis' }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/duplicate or aliased/u) +}) +it('rejects a host package declared as an ordinary dependency', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { '@deepseek-ai/cordis': '^1.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/peer dependency/u) +}) +it('rejects incompatible peers only when the plugin is enabled', () => { + const { dsh, runtime, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin', { peerDependencies: { '@deepseek-ai/cordis': '^2.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/found 1.0.0/u) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, []) }).not.toThrow() +}) +it('refuses to satisfy a plugin dependency from an ancestor CLI project', () => { + const { root, dsh, runtime, profile } = fixture() + writePackage(join(root, 'node_modules'), 'ambient') + writePackage(join(profile, 'node_modules'), 'plugin', { dependencies: { ambient: '1.0.0' } }) + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/outside its owned packages/u) +}) +it('removes broken owned links without following them', () => { + const { root, profile } = fixture() + writePackage(join(profile, 'node_modules'), 'plugin') + rmSync(join(root, 'dsh'), { recursive: true }) + expect(() =>{ unlinkDesktopHostPackages(profile) }).not.toThrow() +}) +it('refuses to replace an unowned package at a managed name', () => { + const { profile } = fixture() + unlinkSync(join(profile, 'node_modules/@deepseek-ai/cordis')) + writePackage(join(profile, 'node_modules'), '@deepseek-ai/cordis') + expect(() =>{ unlinkDesktopHostPackages(profile) }).toThrow(/unowned package/u) +}) +it('rejects private package links instead of following cycles or old transaction paths', () => { + const { dsh, runtime, profile } = fixture() + const plugin = writePackage(join(profile, 'node_modules'), 'plugin') + symlinkSync(plugin, join(profile, 'node_modules/alias'), process.platform === 'win32' ? 'junction' : 'dir') + expect(() =>{ validateDesktopPluginGraph(profile, dsh, runtime, ['plugin']) }).toThrow(/linked private package/u) +}) diff --git a/apps/desktop/tests/project-manager.spec.ts b/apps/desktop/tests/project-manager.spec.ts index 27ac24fce4..c618e69393 100644 --- a/apps/desktop/tests/project-manager.spec.ts +++ b/apps/desktop/tests/project-manager.spec.ts @@ -1,168 +1,65 @@ -import { createHash } from 'node:crypto' -import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, relative, sep } from 'node:path' +import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { resolveDesktopPaths } from '../src/paths.ts' -import { - createSeedMetadata, - DesktopProjectManager, - packageNameFromSpec, - verifySeedIntegrity, - type DesktopProjectHooks, -} from '../src/project-manager.ts' -import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' -import { DESKTOP_PACKAGES_DIR, DESKTOP_PACKAGE_SET_FILE } from '../src/core-package-set.ts' -import type { DesktopRelease } from '../src/release.ts' -import { archivePnpmStore } from '../src/seed-store.ts' +import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts' +import { runtimeFixture } from './runtime-fixture.ts' const roots: string[] = [] const releaseWorkers: Array<() => Promise> = [] - function temporaryRoot(): string { const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-')) roots.push(root) return root } - -function writeIntegrity(seed: string): void { - const paths: string[] = [] - const visit = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) visit(path) - else if (entry.name !== 'integrity.json') paths.push(path) - } - } - visit(seed) - const files = paths.sort().map((path) => { - const body = readFileSync(path) - return { - path: relative(seed, path).split(sep).join('/'), - bytes: statSync(path).size, - sha256: createHash('sha256').update(body).digest('hex'), - } - }) - writeFileSync(join(seed, 'integrity.json'), `${JSON.stringify({ schemaVersion: 2, files })}\n`) -} - -function archiveStore(seed: string): void { - const store = join(seed, 'store') - mkdirSync(store, { recursive: true }) - if (readdirSync(store).length === 0) writeFileSync(join(store, 'test-entry'), 'content') - archivePnpmStore(seed, store) -} - -function writeCorePackageSet(seed: string, version: string): void { - const packages = [ - { name: '@deepseek-ai/dsh', file: `deepseek-ai-dsh-${version}.tgz`, body: Buffer.from(`dsh-${version}`) }, - { - name: '@deepseek-ai/dsh-desktop-host', - file: `deepseek-ai-dsh-desktop-host-${version}.tgz`, - body: Buffer.from(`desktop-host-${version}`), - }, - ] - mkdirSync(join(seed, DESKTOP_PACKAGES_DIR), { recursive: true }) - for (const entry of packages) writeFileSync(join(seed, DESKTOP_PACKAGES_DIR, entry.file), entry.body) - writeFileSync(join(seed, DESKTOP_PACKAGE_SET_FILE), `${JSON.stringify({ - schemaVersion: 1, - packages: packages.map(({ name, file, body }) => ({ - name, - version, - file, - bytes: body.byteLength, - integrity: `sha512-${createHash('sha512').update(body).digest('base64')}`, - })), - })}\n`) -} - -function createTestSeedMetadata(seed: string, desktopRelease: DesktopRelease): void { - writeCorePackageSet(seed, desktopRelease.version) - createSeedMetadata(seed, desktopRelease) -} - function writeFakePnpm(root: string): string { const path = join(root, 'pnpm.mjs') - writeFileSync(path, String.raw` -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' + writeFileSync(path, ` +import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' const args = process.argv.slice(2) const project = process.cwd() -const command = args.find(value => value === 'install' || value === 'add' || value === 'remove') -const manifestPath = join(project, 'package.json') -const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) -const packageName = spec => spec.startsWith('@') - ? spec.slice(0, spec.indexOf('@', spec.indexOf('/') + 1) === -1 ? undefined : spec.indexOf('@', spec.indexOf('/') + 1)) - : spec.split('@')[0] -const packageVersion = spec => { - const index = spec.startsWith('@') ? spec.indexOf('@', spec.indexOf('/') + 1) : spec.indexOf('@') - return index === -1 ? '1.0.0' : spec.slice(index + 1) -} - -if (command === 'add') { - const spec = args[args.indexOf('add') + 1] - manifest.dependencies[packageName(spec)] = packageVersion(spec) -} -if (command === 'remove') delete manifest.dependencies[args[args.indexOf('remove') + 1]] -writeFileSync(manifestPath, JSON.stringify(manifest)) -rmSync(join(project, 'node_modules'), { recursive: true, force: true }) -for (const [name, version] of Object.entries(manifest.dependencies)) { - const packageRoot = join(project, 'node_modules', ...name.split('/')) - mkdirSync(packageRoot, { recursive: true }) - const core = name === '@deepseek-ai/dsh' || name === '@deepseek-ai/dsh-desktop-host' - const plugin = !core - const installedVersion = plugin - ? version - : JSON.parse(readFileSync(join(project, 'desktop-release.json'), 'utf8')).version - writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ - name, version: installedVersion, - ...(plugin ? { dsh: { bundle: { patch: './bundle.yml' } } } : {}), - })) - if (plugin) writeFileSync(join(packageRoot, 'bundle.yml'), '[]\n') - else if (name === '@deepseek-ai/dsh-desktop-host') { - mkdirSync(join(packageRoot, 'lib'), { recursive: true }) - writeFileSync(join(packageRoot, 'lib', 'index.js'), '') +const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value)) +appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY}) + '\\n') +if (command !== 'rebuild') { + const manifestPath = join(project, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (command === 'add') { + const spec = args[args.indexOf(command) + 1] + const index = spec.lastIndexOf('@') + const name = index > 0 ? spec.slice(0, index) : spec + manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0' } + if (command === 'remove') delete manifest.dependencies[args[args.indexOf(command) + 1]] + writeFileSync(manifestPath, JSON.stringify(manifest)) + rmSync(join(project, 'node_modules'), { recursive: true, force: true }) + for (const [name, version] of Object.entries(manifest.dependencies)) { + const packageRoot = join(project, 'node_modules', name) + mkdirSync(packageRoot, { recursive: true }) + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version, + peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}})) + writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n') + } + writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies)) } -writeFileSync(join(project, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') -if (process.env.TEST_PNPM_LOG) writeFileSync(process.env.TEST_PNPM_LOG, JSON.stringify({ args, env: process.env })) `) return path } - -function writeBlockingFakePnpm(root: string, ready: string, release: string): string { - const path = join(root, 'blocking-pnpm.mjs') - const delegate = writeFakePnpm(root) - writeFileSync(path, ` -import { existsSync, writeFileSync } from 'node:fs' -import { setTimeout as sleep } from 'node:timers/promises' -writeFileSync(${JSON.stringify(ready)}, String(process.pid)) -while (!existsSync(${JSON.stringify(release)})) await sleep(10) -await import(${JSON.stringify(pathToFileURL(delegate).href)}) -`) - return path -} - function hooks(overrides: Partial = {}): DesktopProjectHooks { - return { - healthCheck: async () => {}, - beforeActivate: async () => {}, - afterActivate: async () => {}, - ...overrides, - } + return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides } } - -function release(version = '1.0.0'): DesktopRelease { - return { - schemaVersion: 1, - version, - hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION, - nodeVersion: '24.17.0', - pnpmVersion: '11.7.0', - } +function setup(): { root: string; manager: DesktopProjectManager } { + const root = temporaryRoot() + const dsh = join(root, 'resources', 'dsh') + runtimeFixture(dsh) + return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) } +} +function calls(root: string): { args: string[]; registry: string }[] { + const path = join(root, 'pnpm-log.jsonl') + return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : [] } - afterEach(async () => { const cleanups = releaseWorkers.splice(0) const directories = roots.splice(0) @@ -172,239 +69,333 @@ afterEach(async () => { if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed') }) -describe('desktop package policy', () => { - it('accepts registry package specs but rejects alternate sources and flags', () => { +describe('desktop external plugin profile', () => { + it('reuses plugin files without scanning manifests and can disable or reset them', async () => { + const { manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json') + writeFileSync(manifest, '{broken') + await expect(manager.applyRelease()).resolves.toBe(false) + await manager.mutate({ type: 'plugins-disable-all' }, hooks()) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(readFileSync(manifest, 'utf8')).toBe('{broken') + await manager.resetConfiguration(hooks()) + expect(existsSync(manifest)).toBe(false) + await expect(manager.applyRelease()).resolves.toBe(false) + }) + + it('disables every third-party bundle without reading a broken plugin patch declaration', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml') + unlinkSync(patch) + await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => { + expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as { + dsh: { profile: { bundles: string[] } } + }).dsh.profile.bundles).not.toContain('plugin') + } })) + expect((JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8')) as { + dsh: { profile: { bundles: string[] } } + }).dsh.profile.bundles).not.toContain('plugin') + expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true) + expect(calls(root)).toHaveLength(2) + await expect(manager.applyRelease()).resolves.toBe(false) + }) + + it('resets the entire profile without backups while retaining its lock and shared data', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const profile = manager.paths.profile + expect(manager.paths.lock).toBe(join(profile, 'lock')) + const task = join(root, '.dsh', 'task-sentinel') + const homeEnvironment = join(root, '.dsh', '.env') + writeFileSync(homeEnvironment, 'HOME_SETTING=retained') + writeFileSync(task, 'retained task') + writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken') + writeFileSync(join(profile, 'cordis.patch.yml'), ': broken') + writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad') + mkdirSync(join(profile, '.extra')) + writeFileSync(join(profile, '.extra', 'custom-file'), 'remove') + const shared = join(root, 'shared-data') + mkdirSync(shared) + writeFileSync(join(shared, 'sentinel'), 'preserve') + symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir') + await expect(manager.applyRelease()).rejects.toThrow() + await manager.resetConfiguration(hooks({ + beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') }, + afterChange: async () => { + manager.assertProfileRuntime(profile) + expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid)) + await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active') + }, + })) + expect(manager.listPlugins()).toEqual([]) + expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false) + expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false) + expect(existsSync(join(profile, '.env'))).toBe(false) + expect(existsSync(join(profile, '.extra'))).toBe(false) + expect(existsSync(join(profile, 'external-link'))).toBe(false) + expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve') + expect(readFileSync(task, 'utf8')).toBe('retained task') + expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained') + expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false) + expect(calls(root)).toHaveLength(2) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(existsSync(homeEnvironment)).toBe(true) + }) + + it('reports damaged application metadata as a reinstall failure', async () => { + const { manager } = setup() + writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken') + await expect(manager.applyRelease()).rejects.toThrow() + expect(manager.canRecoverProfile()).toBe(false) + }) + + it('accepts registry names and tags but rejects alternate sources and flags', () => { expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin') expect(packageNameFromSpec('plugin@next')).toBe('plugin') - expect(() => packageNameFromSpec('file:../plugin')).toThrow(/unsupported npm package spec/u) - expect(() => packageNameFromSpec('--registry=evil')).toThrow(/unsupported npm package spec/u) - expect(() => packageNameFromSpec('https://example.test/plugin.tgz')).toThrow(/unsupported npm package spec/u) - }) - - it('rejects any seed content changed after release inventory generation', () => { - const seed = join(temporaryRoot(), 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - writeIntegrity(seed) - expect(() => { verifySeedIntegrity(seed) }).not.toThrow() - writeFileSync(join(seed, 'package.json'), '{}\n') - expect(() => { verifySeedIntegrity(seed) }).toThrow(/integrity verification failed/u) - }) -}) - -describe('desktop project transactions', () => { - it('installs the offline seed and reconciles a mismatched private Host', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const log = join(root, 'pnpm-log.json') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(seed, 'store'), { recursive: true }) - writeFileSync(join(seed, 'store', 'seed-entry'), 'content') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - const previousLog = process.env.TEST_PNPM_LOG - const previousRegistry = process.env.npm_config_registry - process.env.TEST_PNPM_LOG = log - process.env.npm_config_registry = 'https://user-registry.invalid' - try { - await expect(manager.applyRelease(seed, '2.0.0', hooks())).rejects.toThrow(/does not match Electron/u) - await manager.applyRelease(seed, '1.0.0', hooks()) - writeFileSync( - join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), - '{"name":"@deepseek-ai/dsh-desktop-host","version":"0.9.0"}\n', - ) - await expect(manager.applyRelease(seed, '1.0.0', hooks())).resolves.toBe(true) - } finally { - if (previousLog === undefined) delete process.env.TEST_PNPM_LOG - else process.env.TEST_PNPM_LOG = previousLog - if (previousRegistry === undefined) delete process.env.npm_config_registry - else process.env.npm_config_registry = previousRegistry + for (const spec of ['file:../plugin', '--registry=evil', 'https://example.test/plugin.tgz']) { + expect(() => packageNameFromSpec(spec)).toThrow(/unsupported npm package spec/u) } - expect(manager.dshVersion()).toBe('1.0.0') - expect(manager.releaseVersion()).toBe('1.0.0') - expect(paths.profile).toBe(join(root, '.dsh', 'profiles', 'desktop')) - expect(existsSync(join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh'))).toBe(true) - const installedHost = JSON.parse(readFileSync( - join(paths.profile, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'package.json'), - 'utf8', - )) as { version: string } - expect(installedHost.version).toBe('1.0.0') - expect(existsSync(join(paths.profile, 'desktop-plugins.json'))).toBe(false) - expect(readFileSync(join(paths.pnpm.store, 'seed-entry'), 'utf8')).toBe('content') - const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } - expect(invocation.args).toContain('--offline') - expect(invocation.args).toContain('--trust-lockfile') - expect(invocation.args).toContain(`--config.store-dir=${paths.pnpm.store}`) - expect(invocation.args).toContain('--config.enable-global-virtual-store=false') - expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_STORE_DIR).toBe(paths.pnpm.store) - expect(invocation.env.NPM_CONFIG_USERCONFIG).toBe(join(paths.pnpm.config, 'npmrc')) - expect(invocation.env.npm_config_registry).toBeUndefined() }) - it('restores the active project when the replacement backend cannot start', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) - let starts = 0 - await expect(manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks({ - afterActivate: async () => { - starts += 1 - if (starts === 1) throw new Error('backend rejected staged graph') - }, - }))).rejects.toThrow(/backend rejected staged graph/u) + it('retries installation after an interrupted runtime rebuild removed plugin files', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + const failing = join(root, 'fail-install.mjs') + writeFileSync(failing, 'process.exitCode = 1') + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing }) + await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1') + expect(existsSync(join(manager.paths.profile, 'node_modules/plugin'))).toBe(false) + const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(retry.applyRelease()).resolves.toBe(true) + expect(retry.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + await expect(retry.applyRelease()).resolves.toBe(false) + }) + + it('preserves unknown files when initializing a profile', async () => { + const { manager } = setup() + mkdirSync(manager.paths.profile, { recursive: true }) + writeFileSync(join(manager.paths.profile, '.DS_Store'), 'metadata') + writeFileSync(join(manager.paths.profile, 'user-file'), 'retain') + await expect(manager.applyRelease()).resolves.toBe(true) + expect(readFileSync(join(manager.paths.profile, '.DS_Store'), 'utf8')).toBe('metadata') + expect(readFileSync(join(manager.paths.profile, 'user-file'), 'utf8')).toBe('retain') + }) + + it.each(['plugin-add', 'runtime-change'] as const)('retries failed rebuild after %s across manager instances', async (operation) => { + const { root, manager } = setup() + await manager.applyRelease() + let dsh = manager.runtime.dsh + if (operation === 'runtime-change') { + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + } + const failing = join(root, 'fail-rebuild.mjs') + writeFileSync(failing, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); if (process.argv.includes('rebuild')) process.exitCode = 1`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh, pnpm: failing }) + if (operation === 'plugin-add') { + await worker.applyRelease() + await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())).rejects.toThrow('pnpm exited with 1') + } else await expect(worker.applyRelease()).rejects.toThrow('pnpm exited with 1') + expect(() => { worker.assertProfileRuntime(worker.paths.profile) }).toThrow('package preparation is incomplete') + const count = calls(root).length + const retry = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(retry.applyRelease()).resolves.toBe(true) + expect(calls(root).slice(count).map(call => call.args.find(arg => !arg.startsWith('--config.')))).toEqual(['install', 'rebuild']) + await expect(retry.applyRelease()).resolves.toBe(false) + expect(calls(root)).toHaveLength(count + 2) + }) + + it('initializes and restarts offline without executing pnpm', async () => { + const { root, manager } = setup() + await expect(manager.applyRelease()).resolves.toBe(true) + await expect(manager.applyRelease()).resolves.toBe(false) expect(manager.listPlugins()).toEqual([]) - expect(manager.dshVersion()).toBe('1.0.0') - expect(starts).toBe(2) + expect(calls(root)).toEqual([]) + expect(existsSync(manager.paths.pnpm.store)).toBe(false) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis'))) + expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} }) }) - it('restores rollback when the active move completed before its journal update', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) + it('repairs a removed managed link without running pnpm', async () => { + const { root, manager } = setup() + await manager.applyRelease() + unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')) + await expect(manager.applyRelease()).resolves.toBe(true) + expect(calls(root)).toEqual([]) + }) + + it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => { + const { manager } = setup() + await manager.applyRelease() + const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() }) + await expect(relaunched.applyRelease()).resolves.toBe(false) + }) + + it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => { + const { root, manager } = setup() + if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}') + if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n') + if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '') + if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json')) + await expect(manager.applyRelease()).resolves.toBe(true) + const relaunched = new DesktopProjectManager(manager.paths, manager.runtime) + await expect(relaunched.applyRelease()).resolves.toBe(false) + expect(existsSync(manager.paths.profile)).toBe(true) + expect(calls(root)).toEqual([]) + }) + + it('installs only plugins and checks the graph before running lifecycle scripts', async () => { + const { root, manager } = setup() + await manager.applyRelease() await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - const stagingProfile = join(paths.staging, 'interrupted', 'profile') - mkdirSync(stagingProfile, { recursive: true }) - writeFileSync(join(stagingProfile, 'marker'), 'staging') - rmSync(paths.rollback, { recursive: true, force: true }) - mkdirSync(dirname(paths.rollback), { recursive: true }) - renameSync(paths.profile, paths.rollback) - writeFileSync(paths.pending, `${JSON.stringify({ - schemaVersion: 1, - id: 'interrupted', - stagingProfile, - step: 'prepared', - })}\n`) - - manager.recover() - - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) - expect(existsSync(stagingProfile)).toBe(false) - expect(existsSync(paths.pending)).toBe(false) + expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }]) + expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ + ['add', '@scope/plugin@2.0.0', '--save-exact', '--ignore-scripts'], ['rebuild', '--pending'], + ]) + expect(calls(root).every(call => call.registry === 'https://registry.npmjs.org/')).toBe(true) + expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } }) + await expect(manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis' }, hooks())).rejects.toThrow(/host-owned/u) + await expect(manager.applyRelease()).resolves.toBe(false) + expect(calls(root)).toHaveLength(2) }) - it('records the live pnpm worker as transaction owner until it exits', async ({ task, signal }) => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const ready = join(root, 'pnpm-ready') - const releaseWorker = join(root, 'pnpm-release') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const runtime = { node: process.execPath, pnpm: writeBlockingFakePnpm(root, ready, releaseWorker) } - const manager = new DesktopProjectManager(paths, runtime) - const installing = manager.applyRelease(seed, '1.0.0', hooks()) + it('retains disabled plugin versions through updates and enables them explicitly', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + await manager.mutate({ type: 'plugins-disable-all' }, hooks()) + expect(calls(root)).toHaveLength(2) + expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks()) + expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }]) + await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks()) + expect(manager.listPlugins()[0]?.enabled).toBe(true) + await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) + expect(manager.listPlugins()).toEqual([]) + }) + + it('keeps plugin files and patches through a compatible release and application relocation', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n') + const nextRoot = join(root, 'relocated', 'dsh') + runtimeFixture(nextRoot, '1.1.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot }) + await expect(next.applyRelease()).resolves.toBe(true) + expect(next.listPlugins()).toEqual(manager.listPlugins()) + expect(next.releaseVersion()).toBe('1.1.0') + expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n') + expect(calls(root)).toHaveLength(2) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis'))) + expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n') + }) + + it('reinstalls the locked plugin graph when bundled Node changes', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'new-node') + runtimeFixture(dsh, '1.1.0', '24.18.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await next.applyRelease() + expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([ + ['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'], + ]) + expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }]) + }) + + it('allows incompatible plugins to be disabled in recovery without deleting them', async () => { + const { root, manager } = setup() + await manager.applyRelease() + await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) + const dsh = join(root, 'next-major') + runtimeFixture(dsh, '2.0.0') + const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh }) + await expect(next.applyRelease()).rejects.toThrow(/requires @deepseek-ai\/cordis/u) + expect(next.releaseVersion()).toBe('2.0.0') + await next.mutate({ type: 'plugins-disable-all' }, hooks()) + expect(next.releaseVersion()).toBe('2.0.0') + expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + }) + + it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => { + const { manager } = setup() + await manager.applyRelease() + let starts = 0 + await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ + beforeChange: async () => { + expect(manager.listPlugins()).toEqual([]) + if (phase === 'before') throw new Error('before failed') + }, + afterChange: async () => { starts++; throw new Error('after failed') }, + }))).rejects.toThrow(`${phase} failed`) + expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }]) + expect(starts).toBe(phase === 'before' ? 0 : 1) + expect(existsSync(join(manager.paths.root, 'staging'))).toBe(false) + expect(existsSync(join(manager.paths.root, 'rollback'))).toBe(false) + expect(existsSync(join(manager.paths.root, 'pending.json'))).toBe(false) + }) + + it('keeps partial package changes and restores host links after pnpm fails', async () => { + const { root, manager } = setup() + await manager.applyRelease() + const failingPnpm = join(root, 'failing.mjs') + writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm }) + await worker.applyRelease() + let starts = 0 + await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({ + afterChange: async () => { starts++ }, + }))).rejects.toThrow(/pnpm exited with 1/u) + expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }]) + expect(starts).toBe(0) + expect(existsSync(manager.paths.lock)).toBe(false) + expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))) + .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis'))) + await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks()) + expect(manager.listPlugins()).toEqual([]) + }) + + it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => { + const { root, manager } = setup() + await manager.applyRelease() + const ready = join(root, 'ready') + const release = join(root, 'release') + const blocker = join(root, 'blocking.mjs') + writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`) + const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker }) + await worker.applyRelease() + const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks()) // Teardown observes failures even if the runner has abandoned the test body. - const completed = installing.then(value => ({ value }), (error: unknown) => ({ error })) + const completed = pending.then(value => ({ value }), (error: unknown) => ({ error })) releaseWorkers.push(async () => { - writeFileSync(releaseWorker, 'continue') + writeFileSync(release, 'continue') const outcome = await completed if ('error' in outcome) throw outcome.error }) - // Child startup shares the test budget; an aborted poll must not resume ownership assertions. - await expect.poll(() => { - signal.throwIfAborted() - return existsSync(ready) - }, { timeout: task.timeout }).toBe(true) - signal.throwIfAborted() - const workerPid = Number.parseInt(readFileSync(ready, 'utf8'), 10) - expect(readFileSync(paths.lock, 'utf8')).toBe(`${String(workerPid)}\n`) - const competing = new DesktopProjectManager(paths, runtime) - await expect(competing.applyRelease(seed, '1.0.0', hooks())).rejects.toThrow(/another package transaction is active/u) - writeFileSync(releaseWorker, 'continue') - await expect(installing).resolves.toBe(true) - expect(existsSync(paths.lock)).toBe(false) - }) - - it('keeps core packages local while installing plugins from the desktop registry', async () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const log = join(root, 'pnpm-log.json') - createTestSeedMetadata(seed, release()) - writeFileSync(join(seed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - archiveStore(seed) - writeIntegrity(seed) - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - await manager.applyRelease(seed, '1.0.0', hooks()) - const previousLog = process.env.TEST_PNPM_LOG - process.env.TEST_PNPM_LOG = log try { - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) + // Child startup shares the test budget; an aborted poll must not resume ownership assertions. + await expect.poll(() => { + signal.throwIfAborted() + return existsSync(ready) + }, { timeout: task.timeout }).toBe(true) + signal.throwIfAborted() + expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8')) + await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u) } finally { - if (previousLog === undefined) delete process.env.TEST_PNPM_LOG - else process.env.TEST_PNPM_LOG = previousLog + writeFileSync(release, 'continue') + await pending } - - const manifest = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { - dependencies: Record - } - const coreSpec = manifest.dependencies['@deepseek-ai/dsh'] - expect(coreSpec).toMatch(/^file:\.\/desktop-packages\//u) - expect(readFileSync(join(paths.profile, 'pnpm-workspace.yaml'), 'utf8')) - .toContain(`${JSON.stringify('@deepseek-ai/dsh')}: ${JSON.stringify(coreSpec)}`) - expect(manifest.dependencies['@scope/plugin']).toBe('2.0.0') - const invocation = JSON.parse(readFileSync(log, 'utf8')) as { args: string[]; env: Record } - expect(invocation.args).toContain('add') - expect(invocation.args).toContain('@scope/plugin@2.0.0') - expect(invocation.args).toContain('--config.registry=https://registry.npmjs.org/') - expect(invocation.env.NPM_CONFIG_REGISTRY).toBe('https://registry.npmjs.org/') - }) - - it('reconciles dsh to the packaged release without removing desktop plugins', async () => { - const root = temporaryRoot() - const paths = resolveDesktopPaths(join(root, '.dsh')) - const manager = new DesktopProjectManager(paths, { node: process.execPath, pnpm: writeFakePnpm(root) }) - const firstSeed = join(root, 'seed-1') - createTestSeedMetadata(firstSeed, release('1.0.0')) - writeFileSync(join(firstSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(firstSeed, 'store'), { recursive: true }) - writeFileSync(join(firstSeed, 'store', 'release-1'), 'one') - archiveStore(firstSeed) - writeIntegrity(firstSeed) - await manager.applyRelease(firstSeed, '1.0.0', hooks()) - await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks()) - - const nextSeed = join(root, 'seed-2') - createTestSeedMetadata(nextSeed, release('1.1.0')) - writeFileSync(join(nextSeed, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') - mkdirSync(join(nextSeed, 'store'), { recursive: true }) - writeFileSync(join(nextSeed, 'store', 'release-2'), 'two') - archiveStore(nextSeed) - writeIntegrity(nextSeed) - - await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(true) - expect(manager.releaseVersion()).toBe('1.1.0') - expect(manager.dshVersion()).toBe('1.1.0') - expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0' }]) - const profile = JSON.parse(readFileSync(join(paths.profile, 'package.json'), 'utf8')) as { - dsh: { profile: { bundles: string[] } } - } - expect(profile.dsh.profile.bundles).toEqual([ - '@deepseek-ai/dsh-base', - '@deepseek-ai/dsh-web-app', - '@scope/plugin', - ]) - expect(readFileSync(join(paths.pnpm.store, 'release-1'), 'utf8')).toBe('one') - expect(readFileSync(join(paths.pnpm.store, 'release-2'), 'utf8')).toBe('two') - await expect(manager.applyRelease(nextSeed, '1.1.0', hooks())).resolves.toBe(false) + expect(existsSync(manager.paths.lock)).toBe(false) }) }) diff --git a/apps/desktop/tests/runtime-file-policy.spec.ts b/apps/desktop/tests/runtime-file-policy.spec.ts new file mode 100644 index 0000000000..837fd54bf1 --- /dev/null +++ b/apps/desktop/tests/runtime-file-policy.spec.ts @@ -0,0 +1,81 @@ +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { expect, it } from 'vitest' +import { desktopRuntimeFileExclusion } from '../scripts/runtime-file-policy.ts' +import { verifyDesktopRuntime, writeDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const windows = { platform: 'win32' as const, arch: 'x64' } + +it('omits development artifacts while preserving executable modules, assets and license files', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-file-policy-')) + const source = join(root, 'source') + const output = join(root, 'output') + const removed = [ + 'example/index.d.ts', 'example/index.d.mts', 'example/index.d.cts', + 'example/index.js.map', 'example/index.mjs.map', 'example/index.cjs.map', + 'example/style.css.map', 'example/index.d.ts.map', 'example/index.d.mts.map', + 'example/index.d.cts.map', 'example/tsconfig.tsbuildinfo', + 'fs-ext/build/Release/obj/fs_ext/native.obj', 'fs-ext/build/Release/fs_ext.pdb', + 'fs-ext/build/Release/fs_ext.lib', 'fs-ext/build/Release/fs_ext.exp', + 'fs-ext/build/Release/fs_ext.iobj', 'fs-ext/build/Release/fs_ext.ipdb', + 'fs-ext/build/binding.sln', 'fs-ext/build/config.gypi', + 'fs-ext/build/fs_ext.vcxproj', 'fs-ext/build/fs_ext.vcxproj.filters', + 'node-pty/prebuilds/win32-arm64/conpty.node', + 'node-pty/prebuilds/linux-x64/pty.node', 'node-pty/prebuilds/darwin-x64/pty.node', + 'node-pty/prebuilds/win32-x64/conpty.pdb', + '@koromix/koffi-win32-x64/win32_x64/koffi.lib', + '@mixmark-io/domino/test/entities.html', + '.modules.yaml', '.pnpm-workspace-state-v1.json', '.bin/tool', '.pnpm/cache', + ] + const retained = [ + 'example/index.js', 'example/index.cjs', 'example/index.mjs', 'example/worker.js', + 'example/source.ts', 'example/src/entry.ts', 'example/test/runtime-fixture.json', + 'example/locale.json', 'example/data.map', 'example/module.wasm', + 'example/package.json', 'example/LICENSE', 'example/NOTICE', 'example/README.md', + 'example/native.lib', 'example/native.obj', 'example/symbols.pdb', + 'fs-ext/build/Release/fs_ext.node', 'fs-ext/build/Release/runtime.dll', + 'node-pty/prebuilds/win32-x64/conpty.node', + 'node-pty/prebuilds/win32-x64/conpty_console_list.node', + 'node-pty/prebuilds/win32-x64/conpty/conpty.dll', + 'node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe', + 'node-pty/third_party/conpty/win10-x64/OpenConsole.exe', + '@koromix/koffi-win32-x64/win32_x64/koffi.node', + '@mixmark-io/domino/lib/HTMLParser.js', '@mixmark-io/domino/lib/EntityParser.js', + '@img/sharp-win32-x64/lib/libvips-42.dll', + ] + try { + const runtime = runtimeFixture(source) + const modules = join(source, 'node_modules') + for (const path of [...removed, ...retained]) { + mkdirSync(join(modules, path, '..'), { recursive: true }) + writeFileSync(join(modules, path), `payload:${path}`) + } + cpSync(modules, join(output, 'node_modules'), { + recursive: true, dereference: true, + filter: path => desktopRuntimeFileExclusion(relative(modules, path), windows) === undefined, + }) + for (const path of removed) expect(existsSync(join(output, 'node_modules', path)), path).toBe(false) + for (const path of retained) expect(readFileSync(join(output, 'node_modules', path), 'utf8'), path).toBe(`payload:${path}`) + const sealed = writeDesktopRuntime(output, runtime.release, runtime.sharedPackages.map(entry => entry.name)) + expect(await verifyDesktopRuntime(output, runtime.release.version)).toEqual(sealed) + expect(readFileSync(join(modules, removed[0]!), 'utf8')).toBe(`payload:${removed[0]}`) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('applies package-specific rules inside scoped and nested dependency containers', () => { + expect(desktopRuntimeFileExclusion('outer/node_modules/@mixmark-io/domino/test/data.html', windows)).toBeDefined() + expect(desktopRuntimeFileExclusion('outer\\node_modules\\node-pty\\prebuilds\\win32-arm64\\conpty.node', windows)).toBeDefined() + expect(desktopRuntimeFileExclusion('outer/node_modules/unrelated/test/data.html', windows)).toBeUndefined() + expect(desktopRuntimeFileExclusion('outer/fs-ext/build/Release/fs_ext.lib', windows)).toBeUndefined() +}) + +it('retains native prebuilds for the selected macOS architecture', () => { + const mac = { platform: 'darwin' as const, arch: 'arm64' } + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-arm64/pty.node', mac)).toBeUndefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/darwin-x64/pty.node', mac)).toBeDefined() + expect(desktopRuntimeFileExclusion('node-pty/prebuilds/win32-x64/conpty.node', mac)).toBeDefined() +}) diff --git a/apps/desktop/tests/runtime-fixture.ts b/apps/desktop/tests/runtime-fixture.ts new file mode 100644 index 0000000000..81648dfc5f --- /dev/null +++ b/apps/desktop/tests/runtime-fixture.ts @@ -0,0 +1,42 @@ +/** Temporary materialized packages for Desktop resource and profile behavior tests. */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' +import { DESKTOP_HOST_PROTOCOL_VERSION } from '../src/host-protocol.ts' +import { writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts' + +/** + * Write a package fixture with explicit runtime exports. + * @param modules - Owning node_modules directory. + * @param name - Package name. + * @param fields - Manifest fields. + * @param source - ESM entry contents. + * @returns Installed package directory. + */ +export function writePackage(modules: string, name: string, fields: Record = {}, source = 'export const identity = {}\n'): string { + const path = join(modules, name) + mkdirSync(path, { recursive: true }) + writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.js', ...fields })) + writeFileSync(join(path, 'index.js'), source) + return path +} + +/** + * Seal a minimal release containing Host entry files and a shared Cordis package. + * @param root - New runtime directory. + * @param version - Shell and dsh version. + * @param nodeVersion - Bundled Node version used for native rebuild selection. + * @returns Sealed runtime metadata. + */ +export function runtimeFixture(root: string, version = '1.0.0', nodeVersion = '24.17.0'): DesktopRuntimeDescriptor { + const names = ['@deepseek-ai/dsh', '@deepseek-ai/dsh-desktop-host', '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/cordis'] + for (const name of names) writePackage(join(root, 'node_modules'), name, { version }) + for (const file of DESKTOP_HOST_RUNTIME_FILES) { + const path = join(root, 'node_modules', '@deepseek-ai/dsh-desktop-host', file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '') + } + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n') + return writeDesktopRuntime(root, { schemaVersion: 1, version, nodeVersion, pnpmVersion: '11.7.0', hostProtocolVersion: DESKTOP_HOST_PROTOCOL_VERSION }, names) +} diff --git a/apps/desktop/tests/runtime-tree-read-failure.spec.ts b/apps/desktop/tests/runtime-tree-read-failure.spec.ts new file mode 100644 index 0000000000..042ff77c6d --- /dev/null +++ b/apps/desktop/tests/runtime-tree-read-failure.spec.ts @@ -0,0 +1,49 @@ +import { mkdtempSync, readFile, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +vi.mock('node:fs', async importOriginal => ({ + ...await importOriginal(), + readFile: vi.fn(), +})) + +it('drains outstanding reads before rejecting and stops scheduling after a read failure', async () => { + const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-read-failure-')) + const pending: ((error?: Error) => void)[] = [] + vi.mocked(readFile).mockImplementation((...args: unknown[]) => { + const path = args[0] + const callback = args.at(-1) + if (typeof path !== 'string' || typeof callback !== 'function') throw new Error('unexpected readFile arguments') + const complete = callback as (error: Error | null, body: Buffer | undefined) => void + pending.push((error) => { complete(error ?? null, error === undefined ? readFileSync(path) : undefined) }) + }) + let outcome: Promise | undefined + try { + runtimeFixture(root) + let settled = false + outcome = verifyDesktopRuntime(root, '1.0.0').finally(() => { settled = true }).catch((error: unknown) => error) + const inFlight = pending.length + expect(inFlight).toBeGreaterThan(1) + const failure = new Error('runtime read failed') + pending.shift()!(failure) + try { + // Pending callbacks keep the reads open across a complete event-loop turn. + await setImmediate() + expect(settled).toBe(false) + expect(pending).toHaveLength(inFlight - 1) + } finally { + for (const complete of pending.splice(0)) complete() + } + expect(await outcome).toBe(failure) + expect(pending).toHaveLength(0) + } finally { + for (const complete of pending.splice(0)) complete(new Error('test cleanup')) + await outcome + vi.mocked(readFile).mockReset() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/runtime-tree.spec.ts b/apps/desktop/tests/runtime-tree.spec.ts new file mode 100644 index 0000000000..04a5145d00 --- /dev/null +++ b/apps/desktop/tests/runtime-tree.spec.ts @@ -0,0 +1,107 @@ +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts' +import { DESKTOP_RUNTIME_FILE, desktopRuntimeId, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts' +import { runtimeFixture } from './runtime-fixture.ts' + +const roots: string[] = [] +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'desktop-runtime-')) + roots.push(root) + runtimeFixture(join(root, 'dsh')) + return root +} +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) + +it('verifies a runtime after relocation without depending on build paths', async () => { + const root = fixture() + const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0') + cpSync(join(root, 'dsh'), join(root, 'moved'), { recursive: true }) + expect(desktopRuntimeId(await verifyDesktopRuntime(join(root, 'moved'), '1.0.0'))).toBe(desktopRuntimeId(before)) +}) +it.each(['changed', 'same-size', 'extra', 'missing'])('checks %s runtime bytes only during build verification', async (operation) => { + const dsh = join(fixture(), 'dsh') + const before = readDesktopRuntime(dsh) + if (operation === 'changed') writeFileSync(join(dsh, 'package.json'), '{}') + if (operation === 'same-size') writeFileSync(join(dsh, 'package.json'), '{"type":"Module"}\n') + if (operation === 'extra') writeFileSync(join(dsh, 'extra'), '') + if (operation === 'missing') rmSync(join(dsh, 'package.json')) + expect(readDesktopRuntime(dsh)).toEqual(before) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) +}) +it('rejects filesystem links and incompatible targets', async () => { + const dsh = join(fixture(), 'dsh') + await expect(verifyDesktopRuntime(dsh, '1.0.0', { platform: process.platform, arch: 'wrong' })).rejects.toThrow(/incompatible/u) + symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir') + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/unsupported filesystem/u) +}) +it('reads file inventory records unchanged during startup', () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: unknown[] } + descriptor.files.unshift({ path: '../outside', bytes: -1.5, sha256: 'unchecked', executable: 'unchecked' }) + writeFileSync(path, JSON.stringify(descriptor)) + expect(readDesktopRuntime(dsh).files).toEqual(descriptor.files) +}) +it.each(['missing', 'directory'])('checks a %s Host entry only during build verification', async (operation) => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES[0]) + rmSync(path) + if (operation === 'directory') mkdirSync(path) + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) +}) +it('checks the shell version only during build verification', async () => { + const dsh = join(fixture(), 'dsh') + expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0') + await expect(verifyDesktopRuntime(dsh, '2.0.0')).rejects.toThrow(/does not match Electron/u) +}) +it.each([ + { schemaVersion: 2 }, + { platform: 'other' }, + { arch: 'other' }, + { release: { schemaVersion: 2 } }, + { release: { hostProtocolVersion: 999 } }, + { release: { nodeVersion: 'invalid' } }, + { release: { pnpmVersion: 'invalid' } }, +])('checks release compatibility only during build verification: %j', async (patch) => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const original = readDesktopRuntime(dsh) + const descriptor = { ...original, ...patch, release: { ...original.release, ...patch.release } } + writeFileSync(path, JSON.stringify(descriptor)) + expect(readDesktopRuntime(dsh)).toEqual(descriptor) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/invalid|incompatible/u) +}) +it.each(['missing', 'invalid-json', 'mismatched'])('checks %s shared manifests only during build verification', async (operation) => { + const dsh = join(fixture(), 'dsh') + const before = readDesktopRuntime(dsh) + const path = join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json') + if (operation === 'missing') rmSync(path) + else writeFileSync(path, operation === 'invalid-json' ? '{' : '{}') + expect(readDesktopRuntime(dsh)).toEqual(before) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow() +}) +it('rejects a descriptor that maps a shared package outside node_modules', async () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { sharedPackages: { path: string }[] } + descriptor.sharedPackages[0]!.path = '../outside' + writeFileSync(path, JSON.stringify(descriptor)) + await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/shared package record/u) +}) +it('verifies recorded executable permissions only on Unix', async () => { + const dsh = join(fixture(), 'dsh') + const path = join(dsh, DESKTOP_RUNTIME_FILE) + const descriptor = JSON.parse(readFileSync(path, 'utf8')) as { files: { executable: boolean }[] } + descriptor.files[0]!.executable = !descriptor.files[0]!.executable + writeFileSync(path, JSON.stringify(descriptor)) + if (process.platform === 'win32') await expect(verifyDesktopRuntime(dsh, '1.0.0')).resolves.toMatchObject(descriptor) + else await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u) +}) +it.each(['../outside', '/absolute', 'C:/absolute', 'a\\b', 'a//b', './a'])('rejects nonportable path %s', (path) => { + expect(() => runtimePath('/runtime', path)).toThrow(/invalid relative path/u) +}) diff --git a/apps/desktop/tests/seed-store.spec.ts b/apps/desktop/tests/seed-store.spec.ts deleted file mode 100644 index e0e7d147c3..0000000000 --- a/apps/desktop/tests/seed-store.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it } from 'vitest' -import { - archivePnpmStore, - extractPnpmStoreArchives, - mergePnpmStore, - removePnpmProjectRegistrations, - SEED_STORE_ARCHIVE_DIR, - SEED_STORE_ARCHIVE_MANIFEST, -} from '../src/seed-store.ts' - -const temporaryRoots: string[] = [] - -function temporaryRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-store-')) - temporaryRoots.push(root) - return root -} - -function archiveBytes(seed: string): readonly { path: string; body: Buffer }[] { - return [SEED_STORE_ARCHIVE_MANIFEST, ...readdirSync(join(seed, SEED_STORE_ARCHIVE_DIR))] - .map(path => ({ - path, - body: readFileSync(path === SEED_STORE_ARCHIVE_MANIFEST - ? join(seed, path) - : join(seed, SEED_STORE_ARCHIVE_DIR, path)), - })) -} - -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) - -describe('desktop seed store cleanup', () => { - it('removes project registrations without removing package data', () => { - const storeRoot = temporaryRoot() - mkdirSync(join(storeRoot, 'v11', 'projects', 'temporary-project'), { recursive: true }) - mkdirSync(join(storeRoot, 'v12', 'projects'), { recursive: true }) - mkdirSync(join(storeRoot, 'metadata', 'projects'), { recursive: true }) - writeFileSync(join(storeRoot, 'v11', 'package-data'), 'package') - - removePnpmProjectRegistrations(storeRoot) - - expect(existsSync(join(storeRoot, 'v11', 'projects'))).toBe(false) - expect(existsSync(join(storeRoot, 'v12', 'projects'))).toBe(false) - expect(existsSync(join(storeRoot, 'v11', 'package-data'))).toBe(true) - expect(existsSync(join(storeRoot, 'metadata', 'projects'))).toBe(true) - }) -}) - -describe('desktop seed store merge', () => { - it('preserves installed package records while the verified seed replaces matching records and files', { timeout: 30_000 }, () => { - const root = temporaryRoot() - const source = join(root, 'source') - const destination = join(root, 'destination') - for (const store of [source, destination]) { - mkdirSync(join(store, 'v11', 'files'), { recursive: true }) - const database = new DatabaseSync(join(store, 'v11', 'index.db')) - database.exec('CREATE TABLE package_index (key TEXT PRIMARY KEY, data BLOB NOT NULL) WITHOUT ROWID') - const insert = database.prepare('INSERT INTO package_index (key, data) VALUES (?, ?)') - if (store === source) { - insert.run('seed-only', Buffer.from('seed')) - insert.run('shared', Buffer.from('new')) - } else { - insert.run('plugin-only', Buffer.from('plugin')) - insert.run('shared', Buffer.from('old')) - } - database.close() - } - writeFileSync(join(source, 'v11', 'files', 'shared'), 'new') - writeFileSync(join(destination, 'v11', 'files', 'shared'), 'old') - writeFileSync(join(destination, 'v11', 'files', 'plugin'), 'plugin') - - mergePnpmStore(source, destination) - - const database = new DatabaseSync(join(destination, 'v11', 'index.db'), { readOnly: true }) - const records = database.prepare('SELECT key, data FROM package_index ORDER BY key').all() as { - key: string - data: Uint8Array - }[] - database.close() - expect(records.map(record => [record.key, Buffer.from(record.data).toString()])).toEqual([ - ['plugin-only', 'plugin'], - ['seed-only', 'seed'], - ['shared', 'new'], - ]) - expect(readFileSync(join(destination, 'v11', 'files', 'shared'), 'utf8')).toBe('new') - expect(readFileSync(join(destination, 'v11', 'files', 'plugin'), 'utf8')).toBe('plugin') - }) -}) - -describe('desktop seed store archives', () => { - it('extracts package bytes and executable modes without retaining loose seed files', () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const store = join(seed, 'store') - const executable = join(store, 'v10', 'files', 'native-addon') - mkdirSync(join(store, 'v10', 'files'), { recursive: true }) - writeFileSync(executable, 'native') - chmodSync(executable, 0o755) - writeFileSync(join(store, 'v10', 'files', 'package-data'), 'package') - - archivePnpmStore(seed, store) - const destination = join(root, 'extracted') - extractPnpmStoreArchives(seed, destination) - - expect(existsSync(store)).toBe(false) - expect(readFileSync(join(destination, 'v10', 'files', 'package-data'), 'utf8')).toBe('package') - if (process.platform !== 'win32') { - expect(statSync(join(destination, 'v10', 'files', 'native-addon')).mode & 0o111).toBe(0o111) - } - }) - - it('produces identical shards for identical paths, bytes, and modes', () => { - const root = temporaryRoot() - const seeds = [join(root, 'first'), join(root, 'second')] - for (const [index, seed] of seeds.entries()) { - const store = join(seed, 'store') - mkdirSync(join(store, 'nested'), { recursive: true }) - const paths = index === 0 ? ['alpha', 'nested/beta'] : ['nested/beta', 'alpha'] - for (const path of paths) { - const target = join(store, path) - writeFileSync(target, path) - utimesSync(target, new Date(index * 10_000), new Date(index * 20_000)) - } - archivePnpmStore(seed, store) - } - - const first = archiveBytes(seeds[0] as string) - const second = archiveBytes(seeds[1] as string) - expect(second.map(entry => entry.path)).toEqual(first.map(entry => entry.path)) - expect(second.map(entry => entry.body)).toEqual(first.map(entry => entry.body)) - }) - - it('rejects an archive whose entry count differs from the manifest', () => { - const root = temporaryRoot() - const seed = join(root, 'seed') - const store = join(seed, 'store') - mkdirSync(store, { recursive: true }) - writeFileSync(join(store, 'package-data'), 'package') - archivePnpmStore(seed, store) - const manifestPath = join(seed, SEED_STORE_ARCHIVE_MANIFEST) - const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { - archives: { entries: number }[] - } - const archive = manifest.archives[0] - if (archive === undefined) throw new Error('test seed has no archive') - archive.entries += 1 - writeFileSync(manifestPath, JSON.stringify(manifest)) - - expect(() => { extractPnpmStoreArchives(seed, join(root, 'extracted')) }).toThrow(/unexpected entry count/u) - }) -}) diff --git a/apps/desktop/tests/startup-renderer.spec.ts b/apps/desktop/tests/startup-renderer.spec.ts new file mode 100644 index 0000000000..0c644a21e6 --- /dev/null +++ b/apps/desktop/tests/startup-renderer.spec.ts @@ -0,0 +1,175 @@ +import { readFileSync } from 'node:fs' +import { runInContext } from 'node:vm' +import { fileURLToPath } from 'node:url' +import { JSDOM } from 'jsdom' +import { expect, it, onTestFinished, vi } from 'vitest' +import type { DesktopBackendState } from '../src/backend-controller.ts' +import type { DshDesktopStartupApi } from '../src/ipc.ts' +import { resolveDesktopLocale } from '../src/locale.ts' +import { startupFailureDocument } from '../src/startup-document.ts' + +function startup(locale = 'en', status: Promise = Promise.resolve({ phase: 'starting' })) { + const dom = new JSDOM(readFileSync(new URL('../renderer/startup.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' }) + onTestFinished(() => { + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + dom.window.close() + }) + const listeners = new Set<(state: DesktopBackendState) => void>() + const unsubscribe = vi.fn(() => { listeners.clear() }) + const disablePlugins = vi.fn(async () => {}) + const resetConfiguration = vi.fn(async () => {}) + const restart = vi.fn(async () => {}) + const queried = Promise.withResolvers() + const api: DshDesktopStartupApi = { + protocolVersion: 1, + locale: async () => resolveDesktopLocale(locale), + backend: { + status: () => { queried.resolve(undefined); return status }, + subscribe: (listener) => { listeners.add(listener); return unsubscribe }, + }, + disablePlugins, resetConfiguration, restart, + } + Object.defineProperty(dom.window, 'dshDesktop', { value: api }) + runInContext(readFileSync(new URL('../renderer/startup.js', import.meta.url), 'utf8'), dom.getInternalVMContext()) + const document = dom.window.document + const element = (selector: string): HTMLElement => { + const result = document.querySelector(selector) + if (result === null) throw new Error(`Missing startup element: ${selector}`) + return result + } + const button = (selector: string): HTMLButtonElement => { + const result = document.querySelector(selector) + if (result === null) throw new Error(`Missing startup button: ${selector}`) + return result + } + const publish = (state: DesktopBackendState): void => { for (const listener of listeners) listener(state) } + const copy = (): string => [element('#title').textContent, element('#description').textContent, + ...['#reset-advice', '#reinstall-advice', '#error', '#actions'].filter(selector => !element(selector).hidden) + .flatMap(selector => selector === '#actions' + ? [...document.querySelectorAll('#actions button')].filter(button => !button.hidden).map(button => button.textContent) + : [element(selector).textContent]), + ].join('\n') + return { dom, document, element, button, publish, copy, + disablePlugins, resetConfiguration, restart, unsubscribe, queried: queried.promise } +} + +it('shows English loading and recovery actions without a Host document', async () => { + const page = startup() + await expect.poll(() => page.element('#title').textContent).not.toBe('') + expect(page.copy()).toMatchInlineSnapshot(` + "Starting DeepSeek Harness… + Your workspace will open when it is ready." + `) + expect(page.element('main').getAttribute('aria-busy')).toBe('true') + expect(page.element('#spinner').hidden).toBe(false) + expect(page.element('#actions').hidden).toBe(true) + expect(page.button('#restart').disabled).toBe(true) + expect(page.button('#disable-plugins').disabled).toBe(true) + page.publish({ phase: 'error', profileRecovery: true, message: 'Plugin failed to load' }) + expect(page.copy()).toMatchInlineSnapshot(` + "DeepSeek Harness could not start + Choose a recovery action below. Disabling third-party plugins retains their files. + Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained. + If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately. + Plugin failed to load + Close and restart + Disable all third-party plugins and retry + Reset Desktop and retry" + `) + expect(page.element('main').getAttribute('aria-busy')).toBe('false') + expect(page.element('#spinner').hidden).toBe(true) + page.button('#restart').click() + expect(page.restart).toHaveBeenCalledOnce() + expect(page.element('#actions').hidden).toBe(true) + expect(page.element('#error').textContent).toBe('') + expect(page.element('main').getAttribute('aria-busy')).toBe('true') +}) + +it('shows Chinese loading and recovery copy', async () => { + const page = startup('zh-CN') + await expect.poll(() => page.element('#title').textContent).not.toBe('') + expect(page.document.documentElement.lang).toBe('zh-CN') + expect(page.copy()).toMatchInlineSnapshot(` + "正在启动 DeepSeek Harness… + 准备就绪后将自动打开工作区。" + `) + page.publish({ phase: 'error', profileRecovery: true, message: '插件加载失败' }) + expect(page.copy()).toMatchInlineSnapshot(` + "DeepSeek Harness 无法启动 + 请选择下方的恢复操作。禁用第三方插件会保留插件文件。 + 重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。 + 如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。 + 插件加载失败 + 关闭并重启 + 禁用全部第三方插件并重试 + 重置 Desktop 并重试" + `) +}) + +it('renders diagnostic markup as text and exposes failures from recovery actions', async () => { + const page = startup() + await expect.poll(() => page.element('#title').textContent).not.toBe('') + const diagnostic = '' + page.publish({ phase: 'error', profileRecovery: true, message: diagnostic }) + expect(page.element('#error').textContent).toBe(diagnostic) + expect(page.element('#error').childElementCount).toBe(0) + page.restart.mockRejectedValueOnce(new page.dom.window.Error('Retry failed')) + page.button('#restart').click() + await expect.poll(() => page.element('#error').textContent).toBe('Retry failed') + expect(page.button('#restart').disabled).toBe(false) + expect(page.button('#disable-plugins').disabled).toBe(false) +}) + +it('keeps subscribed state when initial status arrives late and detaches on pagehide', async () => { + const initial = Promise.withResolvers() + const page = startup('en', initial.promise) + await page.queried + page.publish({ phase: 'error', profileRecovery: true, message: 'Fresh startup failure' }) + initial.resolve({ phase: 'starting' }) + await initial.promise + expect(page.element('#error').textContent).toBe('Fresh startup failure') + expect(page.element('#actions').hidden).toBe(false) + page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) + expect(page.unsubscribe).toHaveBeenCalledOnce() + page.publish({ phase: 'starting' }) + expect(page.element('#error').textContent).toBe('Fresh startup failure') + page.dom.window.dispatchEvent(new page.dom.window.Event('pagehide')) + expect(page.unsubscribe).toHaveBeenCalledOnce() +}) + +it.each(['en', 'zh-CN'])('offers recovery actions with %s guidance and preserves diagnostic text', async (locale) => { + const page = startup(locale) + await expect.poll(() => page.button('#restart').disabled).toBe(true) + page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' }) + await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-profile.txt`, import.meta.url))) + for (const action of ['#disable-plugins', '#reset-configuration', '#restart']) { + page.publish({ phase: 'error', profileRecovery: true, message: 'Failure details' }) + page.button(action).click() + expect(page.element('#actions').hidden).toBe(true) + } + expect(page.disablePlugins).toHaveBeenCalledOnce() + expect(page.resetConfiguration).toHaveBeenCalledOnce() + expect(page.restart).toHaveBeenCalledOnce() + page.publish({ phase: 'error', profileRecovery: false, message: 'Failure details' }) + expect(page.button('#disable-plugins').hidden).toBe(true) + expect(page.button('#reset-configuration').hidden).toBe(true) + await expect(`${page.copy()}\n`).toMatchFileSnapshot(fileURLToPath(new URL(`./expected/startup-${locale}-restart.txt`, import.meta.url))) +}) + +it('keeps emergency diagnostics inert without shell assets', () => { + const html = startupFailureDocument(resolveDesktopLocale('zh-CN'), '', true) + const dom = new JSDOM(html) + expect(dom.window.document.querySelector('script')).toBeNull() + expect(dom.window.document.querySelector('pre')?.textContent).toBe('') + expect(dom.window.document.querySelector('p')?.textContent).toContain('重新安装') + expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual([ + 'dsh-recovery://restart', 'dsh-recovery://plugins', 'dsh-recovery://reset', + ]) + dom.window.close() +}) + +it('offers only restart before emergency profile recovery is available', () => { + const dom = new JSDOM(startupFailureDocument(resolveDesktopLocale('en'), 'Resources unavailable')) + expect([...dom.window.document.querySelectorAll('form')].map(form => form.action)).toEqual(['dsh-recovery://restart']) + dom.window.close() +}) diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 6f7fa58c8d..d34cf1bf26 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -12,19 +12,16 @@ export default defineConfig([ clean: false, deps: { neverBundle: ['electron'] }, }, - { + ...(['preload', 'preload-app'] as const).map(name => ({ // Sandboxed Electron preloads run as CommonJS even though the application package is ESM. - entry: { - preload: 'lib/types/preload.js', - 'preload-app': 'lib/types/preload-app.js', - }, + entry: { [name]: `lib/types/${name}.js` }, outDir: 'lib', - format: ['cjs'], - platform: 'node', + format: ['cjs'] as const, + platform: 'node' as const, target: 'es2024', fixedExtension: false, dts: false, clean: false, deps: { neverBundle: ['electron'] }, - }, + })), ]) diff --git a/apps/web/package.json b/apps/web/package.json index 6efa855df2..cc504af02b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 673cf040bb..a06c0da136 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -320,7 +320,8 @@ describe('web e2e: agent-preset selection', () => { await writeComposerDraft(page, composer, '/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .not.toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) - const onMinimal = await menuOptions(page) + // Rows read as `Title Description`; the title is the capitalized command name. + const onMinimal = (await menuOptions(page)).map(option => option.toLowerCase()) expect(onMinimal.some(option => option.startsWith('compact'))).toBe(false) expect(onMinimal.some(option => option.startsWith('plan'))).toBe(false) // Preset-scoped commands follow the switch; the client's own model command @@ -340,7 +341,7 @@ describe('web e2e: agent-preset selection', () => { await writeComposerDraft(page, composer, '/') await expect.poll(() => menuOptions(page), { timeout: 15_000 }) .toEqual(expect.arrayContaining([expect.stringContaining(SKILL_NAME)])) - const onStandard = await menuOptions(page) + const onStandard = (await menuOptions(page)).map(option => option.toLowerCase()) expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) expect(onStandard.some(option => option.startsWith('goal'))).toBe(true) expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index d7292382df..e30ac22630 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -101,7 +101,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // Skip the resident fixture's three questions, then resolve its approval so // the ordinary composer bar (which owns ContextMeter) resumes. for (let index = 0; index < 3; index += 1) { - fireEvent.click(await screen.findByRole('button', { name: 'Skip this question' })) + fireEvent.click(await screen.findByRole('button', { name: 'Skip' })) } fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })) diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index c3678f3ab7..07b3a91ce1 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -192,9 +192,7 @@ - button "Back to bottom": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 2ae1fa24fa..98df1545df 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -50,9 +50,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index b8b2c1f554..c4be6f4416 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -42,9 +42,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index a9d28a21e7..e7fbb563ba 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -16,9 +16,7 @@ - img - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index e09d2dc04f..c600eb94a8 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -50,9 +50,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 8d8f01047a..7db76e3b44 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -36,9 +36,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 6a9dfa0298..aeab2ddb9d 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -41,9 +41,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 0f4c7db9f8..8afbbbb723 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -45,9 +45,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 5ca8cc92cc..2e0920a20d 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -15,9 +15,7 @@ - img - text: Session recall Research notes - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 7ec7cdb904..2137e87a2f 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -46,9 +46,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 7224220046..cfa24f28a9 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -38,9 +38,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index d76b371ba1..171ce5ee7e 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -461,9 +461,7 @@ - button "Back to bottom": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 5fcde028a1..78e31b7a34 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -33,9 +33,7 @@ - button "Copy": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index c4dbd84fb6..81f046229b 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -52,9 +52,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 6dbe3e41c2..cf6dc3b2d1 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -40,9 +40,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index a2946223dc..7eabc3502f 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -82,7 +82,7 @@ describe('web e2e: /feedback command acknowledgement', () => { await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1) await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') - await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index fdb1330697..4643da9cf8 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -218,12 +218,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const like = page.getByRole('button', { name: 'Good response' }) await like.hover() await like.click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.getByRole('button', { name: 'Instruction understanding and following', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Clear and complete.') + expect(captured()).toHaveLength(releasedCount) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) const rated = page.getByRole('button', { name: 'Remove rating' }) await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true') await expectFeedbackRelease('feedback/message-put', 1) - // Dislike collects the category and note in the dialog; typing releases nothing. + // The second rating uses the same dialog; typing releases nothing. await page.getByRole('button', { name: 'Bad response' }).click() - const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.getByRole('button', { name: 'Task result', exact: true }).click() await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.') expect(captured()).toHaveLength(releasedCount) @@ -242,7 +247,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', { data: { text: 'the second remark' } }, ]) expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([ - { data: { sessionId, item: { rating: 'positive' } } }, + { data: { sessionId, item: { rating: 'positive', note: 'Clear and complete.', category: 'instruction-following' } } }, { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } }, ]) expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }]) @@ -251,7 +256,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', expect(captured()).toHaveLength(releasedCount) const wire = uploads.join('\n') for (const text of ['the diff view is unreadable', 'the second remark', - 'Read both files before answering.']) expect(wire).toContain(text) + 'Clear and complete.', 'Read both files before answering.']) expect(wire).toContain(text) const feedback = events.flatMap>((event) => { switch (event.type) { case 'feedback/record': return [{ type: event.type, text: event.data.text }] diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 70ad37a799..734aa20b03 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -51,7 +51,7 @@ describe('web e2e: /goal human transcript presentation', () => { const input = page.locator('[data-composer-input]').first() await input.fill('/go') const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) - await menu.getByRole('option', { name: 'goal set or view the goal for a long-running task' }) + await menu.getByRole('option', { name: 'Goal Set or view the goal for a long-running task' }) .waitFor({ timeout: 10_000 }) await input.press('Tab') await expect.poll(() => input.textContent()).toBe('/goal ') diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index f42337be37..e01464786a 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -78,13 +78,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await writeComposerDraft(page, input, '') await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' }) }) - const launcher = page.getByRole('button', { name: 'Commands' }) + const launcher = page.getByRole('button', { name: 'Add files or run commands' }) await launcher.click() const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('text: Add') expect(snapshot).toContain('text: Commands') expect(snapshot).not.toContain('text: Skills') expect(snapshot).not.toContain('text: Subagents') @@ -103,7 +104,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () )).toBeLessThan(1) await writeComposerDraft(page, input, '/cpt') await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual([ - 'compactCompact older conversation history', + 'CompactCompact older conversation history', ]) const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(FUZZY_COMMAND_MENU_EXPECTED, fuzzySnapshot, MODE) @@ -118,7 +119,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () try { await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - const launcher = zhPage.getByRole('button', { name: '指令' }) + const launcher = zhPage.getByRole('button', { name: '添加文件或调用指令' }) await launcher.click() const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) await menu.getByRole('option').first().waitFor({ timeout: 10_000 }) @@ -132,6 +133,68 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } }) + it.skipIf(MODE === 'record').each([ + { locale: 'en-US', token: '/goal', row: 'Goal Set or view the goal for a long-running task', hint: 'describe the objective for a long-running task' }, + { locale: 'en-US', token: '/plan', row: 'Plan Enter or leave plan mode', hint: 'describe your task to generate plan' }, + { locale: ZH_BROWSER_LOCALE, token: '/目标', row: '目标 goal 设置或查看长期任务目标', hint: '输入目标,智能体将持续执行' }, + { locale: ZH_BROWSER_LOCALE, token: '/计划', row: '计划 plan 进入或退出计划模式', hint: '描述你的任务以生成计划' }, + ])('keeps $token claimed across separator edits and hides hints during IME composition', async ({ locale, token, row, hint }) => { + const inputPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale }) + const inputTripwire = watchConsole(inputPage) + onTestFailed(() => saveFailureShot(inputPage, `web-e2e-command-input-${locale}-${token.slice(1)}`)) + try { + await inputPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await inputPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const input = inputPage.locator('[data-composer-input]').first() + await writeComposerDraft(inputPage, input, '/') + await inputPage.getByRole('listbox').getByRole('option', { name: row, exact: true }).click() + await expect.poll(() => input.textContent()).toBe(`${token} `) + await input.press('End') + await inputPage.keyboard.insertText('这是任务') + await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`) + for (let i = 0; i < 5; i++) await input.press('Backspace') + const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent() + await expect.poll(() => input.textContent()).toBe(token) + await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed') + await expect.poll(tokenText).toBe(token) + await input.press('Space') + await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed') + await expect.poll(() => input.textContent()).toBe(`${token} `) + await expect.poll(async () => (await tokenText())?.trimEnd()).toBe(token) + const shownHint = () => input.locator('p').last().evaluate(element => getComputedStyle(element, '::after').content) + await expect.poll(shownHint).toBe(JSON.stringify(hint)) + const cdp = await inputPage.context().newCDPSession(inputPage) + await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 }) + await expect.poll(shownHint).toBe('none') + await cdp.send('Input.imeSetComposition', { text: 'zh', selectionStart: 2, selectionEnd: 2 }) + await expect.poll(shownHint).toBe('none') + await cdp.send('Input.insertText', { text: '这' }) + await expect.poll(() => input.textContent()).toBe(`${token} 这`) + await expect.poll(shownHint).toBe('none') + await input.press('Backspace') + await expect.poll(shownHint).toBe(JSON.stringify(hint)) + await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 }) + await expect.poll(shownHint).toBe('none') + await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 }) + await expect.poll(shownHint).toBe(JSON.stringify(hint)) + await input.press('Backspace') + await input.press('Backspace') + await expect.poll(() => input.getAttribute('data-phase')).toBe('plain') + await writeComposerDraft(inputPage, input, '') + const placeholder = inputPage.locator('[data-composer-placeholder]').first() + await expect.poll(() => placeholder.isVisible()).toBe(true) + await cdp.send('Input.imeSetComposition', { text: 'z', selectionStart: 1, selectionEnd: 1 }) + await expect.poll(() => placeholder.isVisible()).toBe(false) + await cdp.send('Input.imeSetComposition', { text: '', selectionStart: 0, selectionEnd: 0 }) + await expect.poll(() => placeholder.isVisible()).toBe(true) + await cdp.detach() + expect(inputTripwire.pageErrors).toEqual([]) + expect(inputTripwire.warnings).toEqual([]) + } finally { + await inputPage.close() + } + }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) @@ -141,10 +204,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd) const input = activePage.locator('[data-composer-input]').first() - await activePage.getByRole('button', { name: 'Commands' }).click() + await activePage.getByRole('button', { name: 'Add files or run commands' }).click() const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) await menu.waitFor({ timeout: 10_000 }) - await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click() + await menu.getByRole('option', { name: 'Plan Enter or leave plan mode' }).click() await expect.poll(() => input.textContent()).toBe('/plan ') await input.press('Enter') const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' }) @@ -365,22 +428,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await recoveryPage.context().setOffline(false) await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true) const connecting = recoveryPage.getByRole('button', { - name: 'Reconnecting automatically, reconnect now', exact: true, + name: 'Reconnecting, reconnect now', exact: true, }) await connecting.waitFor({ timeout: 10_000 }) expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/) const connectingGeometry = await connectionIndicatorGeometry(connecting) expect(await connectionIndicatorTextAlignment(connecting)).toBe('left') - // Animated dots must remain hidden with their state label during hover. - await connecting.evaluate((element) => { - for (const animation of element.getAnimations({ subtree: true })) { - if (!(animation instanceof CSSAnimation)) continue - animation.pause() - animation.currentTime = 1_250 - } - }) + // Hover keeps the state label; the pill never swaps copy or resizes. await connecting.hover() - expect(await connecting.innerText()).toBe('Reconnect now') + expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/) expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) await recoveryPage.mouse.move(0, 0) @@ -398,7 +454,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const indicator = connecting expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry) expect(await connectionIndicatorTextAlignment(indicator)).toBe('left') - await indicator.hover() const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE) const style = await indicator.evaluate((element) => { @@ -435,11 +490,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await connecting.waitFor() await recoveryPage.clock.fastForward(500) await expect.poll(() => sockets.length).toBe(11) - const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) await indicator.hover() - expect(await indicator.innerText()).toBe('Reconnect now') + expect(await indicator.innerText()).toMatch(/^Reconnecting\.{1,3}$/) const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor) - expect(hoverBackground).toBe(idleBackground) await recoveryPage.mouse.down() await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor)) .not.toBe(hoverBackground) @@ -450,7 +503,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const recovered = recoveryPage.getByRole('status') await recovered.waitFor({ timeout: 10_000 }) expect(await recovered.innerText()).toBe('Connected') - expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry) + // The pill sizes to its current label; chrome height and icon box stay fixed. + const recoveredGeometry = await connectionIndicatorGeometry(recovered) + expect(recoveredGeometry.outer[3]).toBe(connectingGeometry.outer[3]) + expect(recoveredGeometry.icon).toEqual(connectingGeometry.icon) expect(await connectionIndicatorTextAlignment(recovered)).toBe('left') await recoveryPage.clock.fastForward(2_000) await recovered.waitFor({ state: 'detached', timeout: 5_000 }) diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 8677beb6d9..df89180c18 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -1,8 +1,8 @@ // Keyless browser regression for durable per-message feedback. Cold-seeds a -// settled two-turn transcript (zero model calls), likes one assistant message -// and sees the acknowledgement, replaces the Like through the Dislike dialog -// with a category and a note, proves the judgment survives a full page reload -// from the Host's canonical log, then retracts it. +// settled two-turn transcript (zero model calls), records a Like through the +// feedback dialog, replaces it through the same dialog with a Dislike, proves +// the judgment survives a full page reload from the Host's canonical log, then +// retracts it. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -20,6 +20,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'message-feedback-web-e2e' +const POSITIVE_NOTE = 'Clear and complete.' const NOTE = 'Read both files before answering.' describe('web e2e: durable per-message feedback', () => { @@ -58,7 +59,7 @@ describe('web e2e: durable per-message feedback', () => { await sessionRow.click() } - it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => { + it.skipIf(MODE === 'record')('submits both ratings through the dialog, persists the Dislike, then retracts it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) await openSeededSession() @@ -71,21 +72,29 @@ describe('web e2e: durable per-message feedback', () => { await like.scrollIntoViewIfNeeded() await like.hover() await like.click() - // A Like records at once and is acknowledged; a recorded rating relabels - // the button to what the next click would do. + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Stability and speed', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(POSITIVE_NOTE) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 }) const rated = page.getByRole('button', { name: 'Remove rating' }).first() await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') - // Dislike opens the Session's feedback dialog; its submission replaces - // the Like with a negative judgment carrying the category and note. + // The same dialog records a negative judgment with its own category and + // note, replacing the positive judgment only after submission. await page.getByRole('button', { name: 'Bad response' }).first().click() - const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) await dialog.waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByRole('textbox', { name: 'Feedback details' }).getAttribute('placeholder')) .toBe('Add details to help us improve. Your submission will include the current conversation log.') await dialog.getByRole('button', { name: 'Task result', exact: true }).click() - await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE) + const details = dialog.getByRole('textbox', { name: 'Feedback details' }) + await details.fill('x'.repeat(8193)) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await page.getByRole('alert').filter({ hasText: 'The description is too long' }).waitFor({ timeout: 10_000 }) + expect(await dialog.count()).toBe(1) + await details.fill(NOTE) await dialog.getByRole('button', { name: 'Submit', exact: true }).click() await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating') @@ -116,9 +125,11 @@ describe('web e2e: durable per-message feedback', () => { await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) if (agent === undefined) throw new Error('seeded session did not attach an agent') - const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1) - expect(put?.type === 'feedback/message-put' ? put.data.item : undefined) - .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' }) + const puts = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put') + expect(puts.map(event => event.type === 'feedback/message-put' ? event.data.item : undefined)).toMatchObject([ + { rating: 'positive', note: POSITIVE_NOTE, category: 'service-stability' }, + { rating: 'negative', note: NOTE, category: 'task-result' }, + ]) // Re-clicking the active rating retracts it, and the note goes with it. await restored.click() diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index a73c9dd65b..a76b88bfb2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -335,7 +335,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const input = page.locator('[data-composer-input]').first() const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await input.fill('/export') - await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) + await page.getByRole('option', { name: /^Export/u }).waitFor({ timeout: 10_000 }) await input.press('Enter') const slashDownload = await slashDownloadPromise expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 514c1ed2c5..bf8eb5e1e8 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -184,6 +184,73 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(await failed.innerText()).toContain('Delivery failed') expect(await delivered.innerText()).toContain('Delivered') await page.locator('[data-turn-process]').click() + const geometry = await page.evaluate(() => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`present layout is missing ${name}`) + return value + } + const answer = requiredElement( + [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .find(element => element.textContent?.includes('PRESENT_DONE')), + 'final answer', + ) + const presentedGrid = requiredElement( + document.querySelector('[data-presented-files-row]'), + 'presented grid', + ) + const presentedRoot = requiredElement(presentedGrid.parentElement, 'presented root') + const turnTail = requiredElement(presentedRoot.closest('[data-turn-tail]'), 'turn tail') + const actions = requiredElement( + turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, + 'action row', + ) + const cards = [...presentedGrid.querySelectorAll('[data-presented-file]')] + const report = requiredElement( + cards.find(card => card.textContent?.includes('report.txt')), + 'report card', + ) + const title = requiredElement( + report.querySelector('span[title="report.txt"]') + ?? [...report.querySelectorAll('span')] + .find(element => element.textContent === 'report.txt'), + 'report title', + ) + const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const open = requiredElement( + report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + 'report open action', + ) + const icon = requiredElement(report.querySelector('svg'), 'report icon') + const secondCard = requiredElement(cards[1], 'second card') + const answerRect = answer.getBoundingClientRect() + const presentedRect = presentedRoot.getBoundingClientRect() + const actionsRect = actions.getBoundingClientRect() + const firstCard = report.getBoundingClientRect() + const secondCardRect = secondCard.getBoundingClientRect() + const gridStyle = getComputedStyle(presentedGrid) + return { + answerToPresented: presentedRect.top - answerRect.bottom, + presentedToActions: actionsRect.top - presentedRect.bottom, + cardHeight: firstCard.height, + cardColumnGap: secondCardRect.left - firstCard.right, + gridColumnGap: gridStyle.columnGap, + gridRowGap: gridStyle.rowGap, + iconWidth: icon.getAttribute('width'), + titleFontSize: getComputedStyle(title).fontSize, + descriptionFontSize: getComputedStyle(description).fontSize, + openFontSize: getComputedStyle(open).fontSize, + } + }) + expect(geometry.answerToPresented).toBeCloseTo(20, 1) + expect(geometry.presentedToActions).toBeCloseTo(20, 1) + expect(geometry.cardHeight).toBeCloseTo(60, 1) + expect(geometry.cardColumnGap).toBeCloseTo(10, 1) + expect(geometry.gridColumnGap).toBe('10px') + expect(geometry.gridRowGap).toBe('10px') + expect(geometry.iconWidth).toBe('20') + expect(geometry.titleFontSize).toBe('13px') + expect(geometry.descriptionFontSize).toBe('10px') + expect(geometry.openFontSize).toBe('12px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 38628b0e8c..8ee9644030 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -171,6 +171,37 @@ describe('web e2e: a finished turn ends with the files it produced', () => { expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0) expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1) + const turnSpacing = await page.evaluate((done) => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`produced-file layout is missing ${name}`) + return value + } + const answer = requiredElement( + [...document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .find(element => element.textContent?.includes(done)), + 'final answer', + ) + const producedRow = requiredElement( + document.querySelector('[data-produced-files-row]'), + 'produced row', + ) + const producedRoot = requiredElement(producedRow.parentElement?.parentElement, 'produced root') + const turnTail = requiredElement(producedRoot.closest('[data-turn-tail]'), 'turn tail') + const actions = requiredElement( + turnTail.querySelector('button[aria-label="Copy"]')?.parentElement, + 'action row', + ) + const answerRect = answer.getBoundingClientRect() + const producedRect = producedRoot.getBoundingClientRect() + const actionsRect = actions.getBoundingClientRect() + return { + answerToProduced: producedRect.top - answerRect.bottom, + producedToActions: actionsRect.top - producedRect.bottom, + } + }, DONE) + expect(turnSpacing.answerToProduced).toBeCloseTo(20, 1) + expect(turnSpacing.producedToActions).toBeCloseTo(20, 1) + const tops = await row.locator(':scope > *:visible').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().top)) expect(new Set(tops.map(top => Math.round(top))).size).toBe(1) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 45ba98f7ac..bd39a95bbd 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -345,7 +345,7 @@ describe('web e2e: resident question composer round trip', () => { expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true }) // Settle the wait so teardown is not racing a pending question. - await composer.getByRole('button', { name: 'Skip this question' }).click() + await composer.getByRole('button', { name: 'Skip' }).click() expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] }) await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index b5fd21d580..88eab9aeb0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -57,7 +57,7 @@ import { type NormalizeContext, } from '@deepseek-ai/dsh-session-snapshot' import { - assertEntriesLoaded, + auditStartupEntries, composeEntries, healProfilesModuleFallback, loadOverlayPatches, @@ -714,7 +714,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { // command/done can arrive before the submit reply releases the composer. await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') - await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') .split(userId).join('{{userId}}') diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 09e24aa7be..4bf1c0c9f5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -293,6 +293,9 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const restoredDialog = page.getByRole('dialog', { name: '设置' }) const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' }) + // The boot palette precedes the settings mirror's saved preference. + const restoredDarkCube = restoredDialog.getByRole('button', { name: '深色' }) + await expect.poll(() => restoredDarkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await selectTheme(systemCube, 'system') await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), { diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index fc0ef1694c..bfc1c9c81e 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -183,8 +183,9 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async }) try { expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({ + definitionId: '@deepseek-ai/dsh-command-feedback', name: 'feedback', - description: 'record feedback about this session', + description: 'Record feedback about this session', input: { hint: '' }, }) } finally { diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 0f1443c8d9..b8d8d36ebe 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -83,7 +83,7 @@ function measureList(page: Page): Promise { }) .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover') - .map(rule => rule.style.getPropertyValue('background')) + .map(rule => rule.style.getPropertyValue('background-color')) const style = getComputedStyle(list) const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index da207c2c52..d155ce5042 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -26,9 +26,7 @@ - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current streaming-fence-highlight-test/streaming-fence": diff --git a/benchmarks/long-session-browser/README.i18n.yaml b/benchmarks/long-session-browser/README.i18n.yaml index 004876c403..060c1346dc 100644 --- a/benchmarks/long-session-browser/README.i18n.yaml +++ b/benchmarks/long-session-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write benchmarks/long-session-browser/README.md -README.md: 009a7a61d9a1a0d4b198ae017def5d3126b713a2 -README.zh.md: df1da8fd76c2f4fa926b6348fa631530fb98181a +README.md: d6bd253dedb2c6cdea7fec1019a14bfcc3c4c56f +README.zh.md: fe593b833002ece8efd18d07707c10708cb76e2e diff --git a/benchmarks/long-session-browser/README.md b/benchmarks/long-session-browser/README.md index 009a7a61d9..d6bd253ded 100644 --- a/benchmarks/long-session-browser/README.md +++ b/benchmarks/long-session-browser/README.md @@ -10,7 +10,7 @@ The required Chromium workflow in [long-session.bench.ts](long-session.bench.ts) ## Measurements -Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Diagnostics report marker state and focus after the first-visible wait, and browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. +Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Enter submits from the focused composer; draft typing retains that focus without a mouse click. Reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before submission, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. Reply markers are sampled on animation frames, with visible text required inside the latest step. The first observation captures marker state and focus in the browser; its diagnostics are retrieved after typing so they add no pre-input round trip. Diagnostics also include browser-clock timestamps and focus at the first input event. They do not pause replay; a delayed first observation or input can still fail overlap. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets. The fixture reserves an empty system head before the first user message, with each user message inside its step. It contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence. diff --git a/benchmarks/long-session-browser/README.zh.md b/benchmarks/long-session-browser/README.zh.md index df1da8fd76..fe593b8330 100644 --- a/benchmarks/long-session-browser/README.zh.md +++ b/benchmarks/long-session-browser/README.zh.md @@ -10,7 +10,7 @@ ## 测量 -三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。诊断报告等待首次可见回复后的标记状态与焦点,以及首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 +三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期 transcript(文本记录)状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首次可见回复、受信任的草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。Enter 从已聚焦的输入框提交;草稿键入保留该焦点,不执行鼠标点击。回复标记查找与输入事件文本观察器仅读取最新 Assistant 步骤,避免重复进行全量历史文本扫描和无障碍扫描。输入事件文本观察器在提交前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。回复标记在动画帧上采样,要求文本在最新步骤内可见。首次观察在浏览器内记录标记状态与焦点;诊断在键入后取回,不增加输入前的通信往返。诊断还包含首个输入事件的浏览器时钟时间戳与焦点。诊断不会暂停回放;首次观察或输入延迟仍可能导致重叠失败。实际首个输入事件必须观察到未完成的回复;完整回复的测量会在 Host 结算后等待新 turn-tail 渲染完成。测量后,在 DONE 之后发送的受信任按键必须无法通过同一个重叠断言。打开、最慢的较早页面和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。 fixture(测试前置数据)在首条用户消息前保留空 system 头节点,每条用户消息都位于其步骤内。它包含混合语言提示词、正文、推理(reasoning)、20 个围栏代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑流,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish 分片构建。其内容不来自模型、工具、外部网络、录制会话或私有 Harness 主目录。流式回复以 16 ms 回放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。 diff --git a/benchmarks/long-session-browser/long-session.bench.ts b/benchmarks/long-session-browser/long-session.bench.ts index c43ec05814..7c5e1ef9ca 100644 --- a/benchmarks/long-session-browser/long-session.bench.ts +++ b/benchmarks/long-session-browser/long-session.bench.ts @@ -52,6 +52,22 @@ function expectInputOverlap(value: boolean): void { expect(value).toBe(true) } +async function waitForReplyMarker(page: Page, marker: string, timeout = 30000) { + return page.waitForFunction(({ marker, first, done }) => { + const reply = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1) + if (!reply) return false + const text = document.createTreeWalker(reply, NodeFilter.SHOW_TEXT) + let node: Node | null + while ((node = text.nextNode())) { + if (!node.textContent?.includes(marker) || !node.parentElement?.checkVisibility({ checkVisibilityCSS: true })) continue + const composer = Array.from(document.querySelectorAll('[data-composer-input][contenteditable="true"]')).at(-1) + const transcript = reply.textContent ?? '' + return { atMs: window.performance.now(), focused: document.activeElement === composer, first: transcript.includes(first), done: transcript.includes(done) } + } + return false + }, { marker, first: FIRST, done: DONE }, { polling: 'raf', timeout }) +} + async function watchInputOverlap(composer: Locator): Promise { await composer.evaluate((element, markers) => { element.removeAttribute('data-benchmark-input-witness') @@ -95,6 +111,22 @@ it('accepts recorded hosted paging and Trajectory medians and rejects slower end } }) +it('waits for visible marker text in the latest Assistant step', async () => { + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.setContent(`
${FIRST}
${FIRST}
`) + await expect(waitForReplyMarker(page, FIRST, 100)).rejects.toThrow('Timeout') + await page.locator('span').evaluate(element => { element.style.visibility = 'visible' }) + const observation = await waitForReplyMarker(page, FIRST) + expect(await observation.jsonValue()).toMatchObject({ first: true, done: false }) + await observation.dispose() + await expect(waitForReplyMarker(page, DONE, 100)).rejects.toThrow('Timeout') + } finally { + await browser.close() + } +}) + it('opens, pages, navigates and streams into a 240-turn browser history', async () => { if (webSnapshotMode() !== 'replay') throw new Error('browser benchmarks require keyless replay mode') const samples: { open: number; page: number; trajectory: number; first: number; streamTask: number; streamWall: number; input: number; inputOverlapped: boolean; heapMb: number; nodes: number }[] = [] @@ -157,22 +189,19 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async await watchInputOverlap(composer) const started = performance.now() await page.keyboard.press('Enter') - const reply = page.locator('[data-chat-flow-kind="assistant-step"]').last() - await reply.getByText(FIRST, { exact: false }).last().waitFor() + const firstMarker = await waitForReplyMarker(page, FIRST) const first = performance.now() - started - const firstObservation = await composer.evaluate((element, markers) => { - const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? '' - return { atMs: window.performance.now(), focused: document.activeElement === element, first: transcript.includes(markers.first), done: transcript.includes(markers.done) } - }, { first: FIRST, done: DONE }) // Keep focus across submission; mouse actionability must not delay the input probe. const input = await measure(page, async () => { await page.keyboard.type('next synthetic question') await expect.poll(() => composer.textContent()).toBe('next synthetic question') }) const inputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true' + const firstObservation = await firstMarker.jsonValue() + await firstMarker.dispose() console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, firstObservation, witness: await composer.getAttribute('data-benchmark-input-witness'), inputTiming: await composer.getAttribute('data-benchmark-input-timing') })) expectInputOverlap(inputOverlapped) - await reply.getByText(DONE, { exact: false }).last().waitFor() + await (await waitForReplyMarker(page, DONE)).dispose() const settlement = await settled if (!settlement.ok) throw settlement.error await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS + 1 }) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index a4aa7ec544..eebbade61d 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: d6b3126197d2812d6d3fe9935238f6459af96e0c -architecture.zh.md: 7bc22159cbe361df2aa1d2f64588c86bf1c511c0 +architecture.md: 2dd820f858e7c10ff21ac88d2c803733bf3d1614 +architecture.zh.md: 6add99a4086f7d3ec76e5e2cbbb9a1a2a3891176 diff --git a/docs/architecture.md b/docs/architecture.md index d6b3126197..2dd820f858 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,9 +50,9 @@ The Python SDK follows the same application architecture. Its runtime wheel pack ## Desktop application -The [Electron desktop application](../apps/desktop/README.md) owns the reserved `$DSH_HOME/profiles/desktop` npm project. Each signed Electron release binds one exact dsh version and carries a first-party offline seed; startup installs that version into the writable profile with the bundled pnpm, while retaining exact desktop-plugin versions from the previous profile. CLI profiles share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules` with Desktop. +The [Electron desktop application](../apps/desktop/README.md) carries its exact dsh production runtime in signed application resources. The reserved `$DSH_HOME/profiles/desktop` contains external plugins and links to host-owned packages; compatible upgrades retain plugin files and refresh these links without installing core dependencies. CLI profiles share supported product data under `$DSH_HOME`, while executable packages, plugin activation, lockfiles, and package-manager state remain separate. -Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the installed dsh backend and matching client graph from the reserved profile. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. +Electron starts the private Desktop Host package under its bundled upstream Node.js process; that package loads the bundled dsh backend and matching client graph together with enabled profile plugins. Unary RPC, Remote streams, and version-matched client assets cross versioned framed byte pipes with Node IPC reserved for lifecycle control, then reach the renderer through the secure `dsh-app://` protocol; the desktop composition opens no Web server or loopback port. Only shell-owned UI can run plugin transactions through the bundled pnpm and its private `$DSH_HOME/desktop/pnpm/store`. ## Core packages diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 7bc22159cb..6add99a408 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -50,9 +50,9 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI ## 桌面应用 -[Electron 桌面应用](../apps/desktop/README.zh.md)持有保留的 `$DSH_HOME/profiles/desktop` npm 项目。每个签名 Electron 发行版绑定一个确切 dsh 版本并携带第一方离线 seed;启动时通过内置 pnpm 把该版本安装进可写 profile,同时保留旧 profile 中桌面插件的确切版本。CLI profile 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、lockfile 或 `node_modules`。 +[Electron 桌面应用](../apps/desktop/README.zh.md)在签名应用资源中携带精确版本的 dsh 生产运行时。保留的 `$DSH_HOME/profiles/desktop` 保存外部插件和指向宿主拥有包的链接;兼容升级保留插件文件并刷新这些链接,无需安装核心依赖。CLI profile 共享 `$DSH_HOME` 下受支持的产品数据,而可执行包、插件激活、锁文件和包管理器状态保持独立。 -Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包从保留 profile 加载已安装的 dsh 后端与匹配的客户端图。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 +Electron 通过内置的上游 Node.js 进程启动私有 Desktop Host 包;该包加载内置 dsh 后端、匹配的客户端图和已启用的 profile 插件。一元 RPC、Remote stream 与版本匹配的客户端资源经带版本的分帧字节管道传输,Node IPC 只保留生命周期控制,再通过安全的 `dsh-app://` 协议到达渲染进程;因此桌面组合不会开放 Web server 或 loopback 端口。只有壳自有 UI 能通过内置 pnpm 及其私有 `$DSH_HOME/desktop/pnpm/store` 执行插件事务。 ## 核心包 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 0c6c8ea98b..aa61c16886 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 9df7b12fbd4ac4971801cc3f0bbdf6632a2ce1a5 -config-catalog.zh.md: bd099ab68f6a7db394106d98b53474af6741729e +config-catalog.md: 2ca57d8eb522712dc6672f3cf20f58cd9ed04469 +config-catalog.zh.md: 760f03224e73c7bc1ddb104095a6e099e3062cf0 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9df7b12fbd..2ca57d8eb5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1101,7 +1101,11 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + /** + * Total-pixel budget replacing the published token-grid projection for one + * deterministic request preview, or the 512-by-512 `low` preset; omission + * projects onto the token grid. + */ imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number @@ -1116,7 +1120,7 @@ export interface DeepSeekCatalogModel { Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts) @@ -1653,7 +1657,7 @@ export interface PresetSpec { Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) -Source: [`packages/interaction/permission-presets/src/index.ts:143`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:144`](../packages/interaction/permission-presets/src/index.ts) @@ -1698,7 +1702,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:63`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts) @@ -1939,7 +1943,7 @@ export interface Config { export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ``` -Source: [`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts) +Source: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) @@ -2629,7 +2633,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts) @@ -3258,7 +3262,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -Source: [`packages/interaction/user-approval/src/index.ts:127`](../packages/interaction/user-approval/src/index.ts) +Source: [`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bd099ab68f..760f03224e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1104,7 +1104,11 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + /** + * Total-pixel budget replacing the published token-grid projection for one + * deterministic request preview, or the 512-by-512 `low` preset; omission + * projects onto the token grid. + */ imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number @@ -1119,7 +1123,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · [`SystemPromptUpdate`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:134`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:129`](../packages/llm/llm-deepseek/src/index.ts) @@ -1656,7 +1660,7 @@ export interface PresetSpec { 依赖:[`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) -来源:[`packages/interaction/permission-presets/src/index.ts:143`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:144`](../packages/interaction/permission-presets/src/index.ts) @@ -1701,7 +1705,7 @@ export interface PlanModeConfig { } ``` -来源:[`packages/plan/plan-mode/src/index.ts:63`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts) @@ -1942,7 +1946,7 @@ export interface Config { export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ``` -来源:[`packages/session-query/session-log-export/src/index.ts:45`](../packages/session-query/session-log-export/src/index.ts) +来源:[`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) @@ -2632,7 +2636,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:242`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:246`](../packages/core/system-prompt/src/index.ts) @@ -3261,7 +3265,7 @@ export interface Config { export type ApprovalPolicy = 'ask' | 'never' ``` -来源:[`packages/interaction/user-approval/src/index.ts:126`](../packages/interaction/user-approval/src/index.ts) +来源:[`packages/interaction/user-approval/src/index.ts:128`](../packages/interaction/user-approval/src/index.ts) diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 537be01dbc..98b54791f3 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-api/fiber.md -fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a -fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b +fiber.md: 3044301effde267bf580c3a5138481b10513f755 +fiber.zh.md: bee2f523f1fa2a2add2afc3a3fc322903f1ef6f0 diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 182b77390b..3044301eff 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -256,8 +256,8 @@ Dispose and immediately reload this plugin with its current config. * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) ``` @@ -269,7 +269,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o - `config` — the new raw config; validated before anything restarts. - `noSave` — hint for persistence hooks not to write the change back. -**Returns** the update waterfall result; the default restart returns a promise. +**Returns** nothing; the restart runs behind the `internal/update` waterfall. [Source](../../vendor/cordis/src/fiber.ts#L736) diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index 9ed3e52618..bee2f523f1 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -258,8 +258,8 @@ dispose 此插件,并立即使用其当前配置重新加载。 * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) ``` @@ -271,7 +271,7 @@ update(config: any, noSave = false) - `config`:新的原始配置;在任何内容重新启动前进行校验。 - `noSave`:提示持久化钩子不要写回此变更。 -**返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。 +**返回**无返回值;重启由 `internal/update` waterfall 执行。 [源码](../../vendor/cordis/src/fiber.ts#L736) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b53bb23b24..ed06a4dba7 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: a8f5a8249c625789da75747db8bccd8fda207d93 -event-producer-consumer.zh.md: 2f1f2cd90d76c8fca592480a878753a682df9ce2 +event-producer-consumer.md: c7ff34f0ea7b7abce78c0852f009f5bc22192490 +event-producer-consumer.zh.md: fe473c2714472a1e7986faefd504b2fc0fafc49a diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a8f5a8249c..c7ff34f0ea 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -29,7 +29,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2f1f2cd90d..fe473c2714 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -31,7 +31,7 @@ | `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 294be6b698..0a49efd211 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 57821e56f6d483dc7fe7bd832be478b475088cc3 -module-graph.zh.md: 87d4541dc9576cc24a8f5efde2dff987d958ba30 +module-graph.md: edf56d23ed8cd99ae41fb977070f6e86e84a3b29 +module-graph.zh.md: 369e9b793ec3261f72014109991449261eafa993 diff --git a/docs/module-graph.md b/docs/module-graph.md index 57821e56f6..edf56d23ed 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -355,6 +355,7 @@ flowchart TD pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] + pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] end subgraph group_typert["packages/typert"] @@ -1173,10 +1174,13 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller pkg_client_test_runtime --> pkg_attachment pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_hmr + pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat pkg_client_test_runtime --> pkg_client_ui_conversation @@ -1184,6 +1188,8 @@ flowchart TD pkg_client_test_runtime --> pkg_client_ui_session pkg_client_test_runtime --> pkg_client_ui_settings pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web + pkg_client_test_runtime --> pkg_remote_mock pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent pkg_client_test_runtime --> pkg_typert_protocol @@ -1291,6 +1297,7 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1464,5 +1471,5 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 87d4541dc9..369e9b793e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -357,6 +357,7 @@ flowchart TD pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] + pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] end subgraph group_typert["packages/typert"] @@ -1175,10 +1176,13 @@ flowchart TD pkg_sdk_jsonrpc_server --> pkg_sdk_protocol pkg_sdk_jsonrpc_server --> pkg_session pkg_sdk_jsonrpc_server --> pkg_subagent + pkg_client_test_runtime --> pkg_api_gateway pkg_client_test_runtime --> pkg_api_session_controller pkg_client_test_runtime --> pkg_api_workspace_controller pkg_client_test_runtime --> pkg_attachment pkg_client_test_runtime --> pkg_client_connection + pkg_client_test_runtime --> pkg_client_hmr + pkg_client_test_runtime --> pkg_client_modules pkg_client_test_runtime --> pkg_client_store pkg_client_test_runtime --> pkg_client_ui_chat pkg_client_test_runtime --> pkg_client_ui_conversation @@ -1186,6 +1190,8 @@ flowchart TD pkg_client_test_runtime --> pkg_client_ui_session pkg_client_test_runtime --> pkg_client_ui_settings pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web + pkg_client_test_runtime --> pkg_remote_mock pkg_client_test_runtime --> pkg_session pkg_client_test_runtime --> pkg_subagent pkg_client_test_runtime --> pkg_typert_protocol @@ -1293,6 +1299,7 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1466,5 +1473,5 @@ flowchart TD | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 389c95a40b..3632731419 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 46e91f23a7aad053791df190f769ad911f334b68 -persistence-catalog.zh.md: 087915b83d4dfd5c45a1583658d6ebe091904a3f +persistence-catalog.md: 7d6c9649628596b04dbe1d75c564a7f5965be940 +persistence-catalog.zh.md: c1736a1f5654f82ea9995b6389630648fc8185eb diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 46e91f23a7..7d6c964962 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -263,7 +263,7 @@ Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:112`](../packages/interaction/commands/src/types.ts) @@ -283,7 +283,7 @@ Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:105`](../packages/interaction/commands/src/types.ts) ### `compaction/*` @@ -570,7 +570,7 @@ Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/sess 'permission/preset': { preset: string } ``` -Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts) +Source: [`packages/interaction/permission-presets/src/index.ts:54`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -587,7 +587,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 087915b83d..c1736a1f56 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -265,7 +265,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:112`](../packages/interaction/commands/src/types.ts) @@ -285,7 +285,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:105`](../packages/interaction/commands/src/types.ts) ### `compaction/*` @@ -572,7 +572,7 @@ export type SessionEvent = { 'permission/preset': { preset: string } ``` -来源:[`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts) +来源:[`packages/interaction/permission-presets/src/index.ts:54`](../packages/interaction/permission-presets/src/index.ts) ### `plan/*` @@ -589,7 +589,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:46`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/src/index.ts) ### `request/*` diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index 620421da84..ed6948f492 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/rescope.md -rescope.md: 676dee2507a974beb20a4c0e8059b6a74aa9c0c9 -rescope.zh.md: 1683111354b1ff7c8771a87b7b03bcbeb0ac0761 +rescope.md: 80a0b1dc67c8b3585f2c18cad3afff9dde5859f9 +rescope.zh.md: aaf65024520308c5d0013d85a128a42839a84573 diff --git a/docs/rescope.md b/docs/rescope.md index 676dee2507..80a0b1dc67 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@ ## What the rename does not touch - **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version. -- **Dependency ranges.** A dependency entry changes its key, never its range: `"cordis": "^4.0.0-rc.7"` becomes `"@deepseek-ai/cordis": "^4.0.0-rc.7"`. `linkWorkspacePackages` resolves those preserved ranges to the pinned workspaces. +- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use `workspace:^` for repository-owned runtime dependencies, so pnpm resolves the pinned local packages and substitutes release ranges when publishing. - **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name. - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index 1683111354..aaf6502452 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R ## 改名不碰什么 - **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。 -- **依赖 range。** 依赖条目只换键、不换范围:`"cordis": "^4.0.0-rc.7"` 变成 `"@deepseek-ai/cordis": "^4.0.0-rc.7"`;`linkWorkspacePackages` 靠这些保留下来的范围把它们解析到固定的 workspace。 +- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单对仓库内的运行时依赖使用 `workspace:^`,因此 pnpm 会解析到固定的本地包,并在发布时替换为版本范围。 - **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。 - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 3e121e78c9..b0560c8499 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/attachment.md -attachment.md: 53b3e51e5b1c87625256178287061386342bc055 -attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1 +attachment.md: db1c1cfa15f5d0815236f96b6472cbbd864decbd +attachment.zh.md: 82379de5049012c0912734dc94257d6b8f354ad1 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 53b3e51e5b..db1c1cfa15 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -126,15 +126,25 @@ interface StoredImageAttachment { ``` ```ts type-equiv -/** Deterministic request-image policy selected by one exact model route. */ -interface ImageRequestPolicy { - /** Maximum width multiplied by height after aspect-preserving projection. */ - maxPixels: number +/** Deterministic request-image target selected by one exact model route for one attachment. */ +interface ImageRequestTarget { + /** Target width in pixels; a target above the source keeps the source width. */ + width: number + /** Target height in pixels; a target above the source keeps the source height. */ + height: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } ``` +```ts type-equiv +/** Integer width and height of one projected image. */ +interface ProjectedDimensions { + width: number + height: number +} +``` + ```ts type-equiv /** Cached request version derived from one provider-independent normalized attachment. */ interface RequestImageAttachment { @@ -157,7 +167,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version at an exact route-chosen target size and encoded-byte target. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -280,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ -readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise +readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 549c06cc5a..82379de504 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -126,15 +126,25 @@ interface StoredImageAttachment { ``` ```ts type-equiv -/** Deterministic request-image policy selected by one exact model route. */ -interface ImageRequestPolicy { - /** Maximum width multiplied by height after aspect-preserving projection. */ - maxPixels: number +/** Deterministic request-image target selected by one exact model route for one attachment. */ +interface ImageRequestTarget { + /** Target width in pixels; a target above the source keeps the source width. */ + width: number + /** Target height in pixels; a target above the source keeps the source height. */ + height: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } ``` +```ts type-equiv +/** Integer width and height of one projected image. */ +interface ProjectedDimensions { + width: number + height: number +} +``` + ```ts type-equiv /** Cached request version derived from one provider-independent normalized attachment. */ interface RequestImageAttachment { @@ -157,7 +167,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切的路由目标尺寸和编码字节目标派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -280,11 +290,11 @@ fileHostPath(ref: FileAttachmentRef): string | undefined /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ -readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise +readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index d2a7bfe3b8..f24adaee4e 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/commands.md -commands.md: 4705048e06796792d8e013450b72035b1f87315b -commands.zh.md: f3af9f6e81745514efd1e073868292a02f90dca8 +commands.md: dc6ad74da872166fbf584dcab8e1ed21878c243a +commands.zh.md: 57a60a87f78cd264acdbffa3f7514b4ac287073c diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 4705048e06..dc6ad74da8 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -33,6 +33,8 @@ interface CommandInputDescriptor { ```ts type-equiv /** Plugin-owned command registration. */ interface CommandDefinition { + /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */ + readonly definitionId?: CommandDefinitionId /** Lowercase command name without the leading slash. */ readonly name: string /** Human-readable summary used in discovery UI. */ @@ -97,6 +99,8 @@ Adapters receive handler-free immutable descriptors after scope resolution. `par ```ts type-equiv /** Handler-free immutable command view returned to UI adapters. */ interface CommandDescriptor { + /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */ + readonly definitionId?: CommandDefinitionId /** Lowercase command name without the leading slash. */ readonly name: string /** Human-readable summary used in discovery UI. */ diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index f3af9f6e81..57a60a87f7 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -33,6 +33,8 @@ interface CommandInputDescriptor { ```ts type-equiv /** Plugin-owned command registration. */ interface CommandDefinition { + /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */ + readonly definitionId?: CommandDefinitionId /** Lowercase command name without the leading slash. */ readonly name: string /** Human-readable summary used in discovery UI. */ @@ -97,6 +99,8 @@ type CommandResult = ```ts type-equiv /** Handler-free immutable command view returned to UI adapters. */ interface CommandDescriptor { + /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */ + readonly definitionId?: CommandDefinitionId /** Lowercase command name without the leading slash. */ readonly name: string /** Human-readable summary used in discovery UI. */ diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index ba31e1ea1d..621fcf80ff 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/feedback.md -feedback.md: 2c6728d97a6a62735e4ef8b97983f08060d4a2c5 -feedback.zh.md: db9f03cb82685763bc3b424c0f6b29738f59ec99 +feedback.md: e153047bcc260bcd06847b42eb6b983ac9247d03 +feedback.zh.md: 60263ea4509c0b10ed9f57d13f1564ced1e066c7 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index 2c6728d97a..e153047bcc 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -27,7 +27,7 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion @@ -298,9 +298,9 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows. -One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read. +One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. The injected `retract` operation rechecks the committed rating inside that queue and becomes a no-op after a concurrent change, so stale UI cannot bypass the dialog by recording a bare rating. A `connection/reset` refreshes only Sessions already read. -Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it. +Either unrecorded rating opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts the selected judgment carrying the chosen category and the trimmed description, or neither; success closes the dialog and shows the acknowledgement toast, while failure keeps the dialog and draft open and shows a warning toast. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it without opening the dialog. ## Boundaries and limitations diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index db9f03cb82..60263ea450 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -27,7 +27,7 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion @@ -298,9 +298,9 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。 -每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 +每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。注入的 `retract` 操作会在该队列内重新检查已提交评分,并在并发变更后变为无操作,因此陈旧 UI 无法绕过弹窗记录裸评分。`connection/reset` 只刷新已读取过的 Session。 -点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会撤回它。 +任一未记录的评分都会打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 所选评分,带上所选分类与去除首尾空白的描述,两者也可都不带;成功会关闭弹窗并显示确认 toast,失败则保留弹窗与草稿并显示警告 toast。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会直接撤回,不打开弹窗。 ## 边界与限制 diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 8ae88da75e..d543a5f4e7 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 77d3313b3c1a025f756e984453bd041abd7b0534 -llm-streaming.zh.md: cac7897ab412f45bd5b5ab20c6460644f18f4ff2 +llm-streaming.md: 28492b46435e3288609129b0dfc5cb40dd5f7296 +llm-streaming.zh.md: fe02e2ebe5b558162e849e26ff05b8d633c07ba0 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 77d3313b3c..28492b4643 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -249,7 +249,7 @@ interface LlmFailure { ## Request-image pricing -An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model pixel budget, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests. +An adapter whose provider charges visual tokens for request images declares per-route pricing by overriding `LlmAdapter.imageRequestPricing`, and `ctx.llm.imageRequestPricing(provider, model)` resolves it synchronously for consumers. The token meter resolves the routed model's pricing on every measurement so compaction pressure, retention, and range selection price image history as the routed request actually sends it; the DeepSeek adapter reproduces its own request projection (per-model request target, oldest-first offload) and prices retained images with the published vision accounting, while provider usage remains the authoritative anchor for completed requests. ```ts type-equiv /** diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index cac7897ab4..fe02e2ebe5 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -251,7 +251,7 @@ interface LlmFailure { ## 请求图片定价 -提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的像素预算、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 +提供方对请求图片收取视觉 token 的适配器通过覆写 `LlmAdapter.imageRequestPricing` 声明按路由的定价,消费方经 `ctx.llm.imageRequestPricing(provider, model)` 同步解析。token 计量服务在每次计量时解析路由模型的定价,使 compaction 的压力、保留与选段都按路由请求实际发送的形式为图片历史计价;DeepSeek 适配器复现自身的请求投影(按模型的请求目标、最旧优先 offload),并用官方公布的视觉计量为保留图片定价,已完成请求仍以 provider usage 为权威锚点。 ```ts type-equiv /** diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index c7296b9ca5..594dcfee20 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 2b99ca8267d35978f245e6bdbf020226c976e363 -session.zh.md: e6cff2d239caf7cd99be13463b412479a6f6e79b +session.md: 7157dfd403d005112a75d0bbb46f99ad385b68df +session.zh.md: 6cb157a8016f236f52d4f8963e6efe070f0b89e3 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 2b99ca8267..7157dfd403 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -473,6 +473,8 @@ declare class Session { ): Session; /** * Return the immutable event stored at one exact sequence number. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -481,6 +483,8 @@ declare class Session { * Materialize an immutable snapshot of a half-open event sequence range. * A full current snapshot is reused until the next append; every previously * returned snapshot remains stable after later appends. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. * @returns a frozen array of the selected deeply frozen events. @@ -491,6 +495,8 @@ declare class Session { ): readonly SessionEvent[]; /** * Return this Session's events after its fork-inherited prefix. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[]; diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index e6cff2d239..6cb157a801 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -475,6 +475,8 @@ declare class Session { ): Session; /** * Return the immutable event stored at one exact sequence number. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -483,6 +485,8 @@ declare class Session { * Materialize an immutable snapshot of a half-open event sequence range. * A full current snapshot is reused until the next append; every previously * returned snapshot remains stable after later appends. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. * @returns a frozen array of the selected deeply frozen events. @@ -493,6 +497,8 @@ declare class Session { ): readonly SessionEvent[]; /** * Return this Session's events after its fork-inherited prefix. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[]; diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index 96577399bd..c4fb972a1d 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: 516e3347c06882bfd3ff42638d77a2acb3aca753 -system-prompt.zh.md: 611ea0e9bdd81585efd7106012edd3d17e7bdd8e +system-prompt.md: 898520e8fc88a9fafd8429723fc48bad3b4ba210 +system-prompt.zh.md: 82e557a309a48f546d7c7fa5a8c1097152b4d145 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index 516e3347c0..898520e8fc 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -56,9 +56,11 @@ interface PromptSection { /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are - * interpolated later, by {@link renderPrompt}. + * interpolated later, by {@link renderPrompt}, unless `interpolate` is false. */ readonly text: string | ((context: AssembleContext) => string) + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + readonly interpolate?: boolean /** * Treat this contribution as the complete system prompt. Assembly still * runs the cooperative waterfall so tools, contexts, and variables can be diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index 611ea0e9bd..82e557a309 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -56,9 +56,11 @@ interface PromptSection { /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are - * interpolated later, by {@link renderPrompt}. + * interpolated later, by {@link renderPrompt}, unless `interpolate` is false. */ readonly text: string | ((context: AssembleContext) => string) + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + readonly interpolate?: boolean /** * Treat this contribution as the complete system prompt. Assembly still * runs the cooperative waterfall so tools, contexts, and variables can be diff --git a/package.json b/package.json index 6f0277cd9d..01e7b245e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "license": "MIT", "private": true, "type": "module", @@ -35,6 +35,7 @@ "package:desktop:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64", "package:desktop:mac:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:mac:x64:dir", "package:desktop:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64", + "package:desktop:win:x64:unsigned": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:unsigned", "package:desktop:win:x64:dir": "pnpm --filter @deepseek-ai/dsh-desktop run package:win:x64:dir", "upload:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:arm64", "upload:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:mac:x64", @@ -133,7 +134,6 @@ "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", - "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index c5617c9349..1e18f54543 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index ab767193d1..4d79b9f4c9 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: a234189cfff09bae894de14dada607a357b398fd -README.zh.md: 7a6e66724724efd83173835095a409c527faccef +README.md: 561081c3a5de716e563eaa9764f387e329365504 +README.zh.md: 9588b194efc64d14a17a69323a14d6118d0e7d7e diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index a234189cff..561081c3a5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -43,7 +43,7 @@ Host composition can register one application event source through `registerRemo Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. -Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. +Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. `ctx.remote.$host` reads the fixed Host facts as plain values: `home` (undefined until the first ready frame) and `isLoopback`. It is not a store — no subscription, no generation counter — so a consumer that must react to reconnection listens for `connection/reset` instead of polling it. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 7a6e667247..9588b194ef 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -43,7 +43,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source 每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。 +每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 `ctx.remote.$host` 以普通值读取固定的 Host 事实:`home`(首个 ready 帧之前为 undefined)与 `isLoopback`。它不是存储——没有订阅、没有代次计数——所以需要响应重连的消费方去监听 `connection/reset`,而不是轮询它。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 255a2382d0..985db89e9e 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 853fc4242f..edb58d3d25 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -728,11 +728,24 @@ function withdrawn(endpoint: string): Extract, { readonly ok return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) } -function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { +/** + * The error branch a carrier throw (offline, transport fault) folds into: `gateway/internal` naming the endpoint and + * the thrown message. Exported so a stand-in for this face folds identically. + * @param endpoint - `/` that was called. + * @param error - what the carrier threw. + * @returns the failed result. + */ +export function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) } -function cancelledFailure(endpoint: string, cause: unknown): Extract, { readonly ok: false }> { +/** + * The error branch a call aborted by its caller folds into: `gateway/cancelled` with the carrier's throw as `cause`. + * @param endpoint - `/` that was called. + * @param cause - what the carrier threw when the signal aborted. + * @returns the failed result. + */ +export function cancelledFailure(endpoint: string, cause: unknown): Extract, { readonly ok: false }> { return { ok: false, error: new RemoteError('gateway/cancelled', `client api: Remote invocation "${endpoint}" was aborted`, {}, { cause }), diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a88cecfbd1..5e0fa5120d 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 10362cdc61..068f9a5057 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -47,7 +47,8 @@ "@deepseek-ai/dsh-api-gateway/client" ], "inject": [ - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-file-upload" ], "platform": "web" } @@ -128,6 +129,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", @@ -136,6 +138,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 35fc113408..a564234664 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -541,6 +541,7 @@ export class SessionCommandController { private async readSessionState(sessionId: SessionId): Promise { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return { id: attached.id, header: attached.header, events: attached.snapshotEvents() } } const inspected = await inspectApiSession(this.ctx, sessionId) @@ -587,6 +588,7 @@ function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId } if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return agent.session.snapshotEvents().some((event) => { if (event.type !== 'user/message') return false const source = event.data.source diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index cfaf00421e..a22badc1f1 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -152,6 +152,7 @@ export class SessionHistoryController { // Constructor seed events have no session/event notification. Normally // only the end-seed suffix is new; if persistence advanced after the // opening observation, replay everything beyond that snapshot cursor. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(snapshotCursor === undefined ? session.firstLiveSeq : SessionLogOffset(snapshotCursor + 1)) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b5e92ec6e9..be6e8d7ebc 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -207,6 +207,7 @@ export class SessionController extends TypertRemoteService { return Promise.resolve({ meta: attached.header, inheritedEventCount: attached.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: attached.snapshotEvents(), }) } diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index e1c28d8bce..53da35e928 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -1,168 +1,98 @@ -import { Context } from '@deepseek-ai/cordis' -import type { Fiber } from '@deepseek-ai/cordis' -import type { - ConnectionGeneration, - ConnectionHandle, -} from '@deepseek-ai/dsh-client-connection/client' -import { - RemoteStreamCarrierError, - RemoteStream, - type RemoteStreamOptions, -} from '@deepseek-ai/dsh-api-gateway/client' +/** + * Session Controller Client apply inside the assembled client: Remote events + * arriving as emit frames on the `$events` stream, the control stream over + * the real Connection, and Agent Context identity through the Typert registry. + */ +import type { Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' -import { afterEach, describe, expect, it, vi } from 'vitest' -import * as SessionClient from '../src/client/index.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { afterEach, describe, expect, vi, type MockInstance } from 'vitest' import { ClientSessions } from '../src/client/sessions/service.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' +import type { SessionListValue } from '../src/types.ts' -const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } } +const SELF = '@deepseek-ai/dsh-api-session-controller' +const ROSTER = webApp.closure([SELF]) +const it = createClientTest({ roster: ROSTER }) +const EVENTS = '$events' +const CONTROL = 'session/control' +const BASELINE = { type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } } +/** The first client boot pays the cold module transform of the cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 const sid = (value: string): SessionId => value as SessionId -type RemoteListener = (...args: never[]) => void - -interface Bench { - readonly ctx: Context - readonly api: FakeApiClient - readonly fiber: Fiber - readonly sessions: ClientSessions - dispatch(event: string, ...args: unknown[]): void - publishGeneration(generation: ConnectionGeneration | undefined): void -} - -const contexts = new Set() - -afterEach(async () => { +afterEach(() => { vi.restoreAllMocks() - await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) - contexts.clear() }) -async function mount(initialGeneration?: ConnectionGeneration): Promise { - const ctx = new Context() - contexts.add(ctx) - await ctx.plugin(TypertRegistry) - const api = new FakeApiClient() - const remote = fakeRemote(api) - const listeners = new Map>() - const generationListeners = new Set<() => void>() - let generation = initialGeneration - const connection: ConnectionHandle = { - isLoopback: true, - generation: { - getSnapshot: () => generation, - subscribe: (listener) => { - generationListeners.add(listener) - return () => { generationListeners.delete(listener) } - }, - }, - state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - reconnect: () => {}, - registerGenerationSource: () => () => {}, - start: () => ({ stop: () => {} }), - } - ctx.reflect.provide('connection', connection) - ctx.reflect.provide('fileUpload', { - available: true, - post: () => Promise.reject(new Error('unexpected file upload')), - }) - ctx.reflect.provide('remote', { - ...remote, - $stream: (options: RemoteStreamOptions) => ( - new RemoteStream(connection, options) - ), - get $host() { - return { home: generation?.host.home, isLoopback: connection.isLoopback } - }, - $on: (event: string, listener: RemoteListener) => { - const eventListeners = listeners.get(event) ?? new Set() - eventListeners.add(listener) - listeners.set(event, eventListeners) - return () => { eventListeners.delete(listener) } - }, - }) - ctx.reflect.provide('remote.commands', remote.commands) - ctx.reflect.provide('remote.session', remote.session) - ctx.reflect.provide('remote.subagents', remote.subagents) - const fiber = ctx.plugin(SessionClient) - await fiber - const sessions = ctx.sessions as ClientSessions - return { - ctx, - api, - fiber, - sessions, - dispatch: (event, ...args) => { - for (const listener of listeners.get(event) ?? []) listener(...args as never[]) - }, - publishGeneration: (next) => { - generation = next - for (const listener of [...generationListeners]) listener() - }, - } +async function bench(start: () => Promise) { + const client = await start() + return { client, sessions: client.ctx.sessions as ClientSessions } } -async function flush(): Promise { - for (let index = 0; index < 12; index++) await Promise.resolve() +/** Deliver one Remote event the way the Host forwards it: an emit frame on the `$events` stream, consumed by the client. */ +async function emit(mock: RemoteMock, event: string, ...args: unknown[]): Promise { + mock.streams.push(EVENTS, { type: 'emit', event, args }) + await mock.streams.drained(EVENTS) +} + +function baselines(accept: MockInstance): number { + return accept.mock.calls.filter(([frame]) => (frame as { type: string }).type === 'baseline').length } describe('Session Controller Client apply', () => { - it('routes Session Remote Events and connection generations into the object layer', async () => { + it('routes Remote events from the $events stream into the object layer and runs handleConnected once per generation', async ({ mock, start }) => { const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') - const bench = await mount() - expect(connected).not.toHaveBeenCalled() + const { client, sessions } = await bench(start) + // The first generation's `connection/reset` already ran it; apply itself saw no Host yet. + await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() }) - bench.dispatch('api-session/added', { - sessionId: sid('session-1'), - updatedAt: 1, - running: false, - blank: true, - }) - await flush() - expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ - running: false, - updatedAt: 1, + await emit(mock, 'api-session/added', { sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true }) + await vi.waitFor(() => { + expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: false, updatedAt: 1 }) }) - bench.dispatch('api-session/status', sid('session-1'), true) - bench.dispatch('api-session/activity', sid('session-1'), 9) - bench.dispatch('api-session/error', sid('session-1'), 'agent failed') - await flush() - expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ - running: true, - updatedAt: 9, + await emit(mock, 'api-session/status', sid('session-1'), true) + await emit(mock, 'api-session/activity', sid('session-1'), 9) + await emit(mock, 'api-session/error', sid('session-1'), 'agent failed') + await vi.waitFor(() => { + expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: true, updatedAt: 9 }) }) expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed') - bench.dispatch('api-session/removed', sid('session-1')) - await flush() - expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() + await emit(mock, 'api-session/removed', sid('session-1')) + await vi.waitFor(() => { expect(sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() }) - bench.ctx.emit('connection/reset') - expect(connected).toHaveBeenCalledOnce() + client.connection.reconnect() + await mock.streams.opened(EVENTS, 2) + await vi.waitFor(() => { expect(connected).toHaveBeenCalledTimes(2) }) + }, COLD_BOOT_TIMEOUT_MS) + + it('runs handleConnected at apply when the Host is already connected, as a reload of the row does', async ({ start }) => { + const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') + const { client } = await bench(start) + await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() }) + await client.reload(SELF) + expect(connected).toHaveBeenCalledTimes(2) }) - it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { + it('accepts the control baseline, retries a carrier loss once, and reports a second opening snapshot as a protocol failure', async ({ mock, start }) => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - const bench = await mount(GENERATION) - await flush() + await start() + await vi.waitFor(() => { expect(baselines(accept)).toBe(1) }) + expect(accept).toHaveBeenCalledWith(BASELINE) - expect(accept).toHaveBeenCalledWith({ - type: 'baseline', - value: { queues: {}, jobs: {}, projections: {} }, - }) + // One immediate retry while the Host is available reopens the stream, whose script pushes the baseline again. + mock.streams.fail(CONTROL, new RemoteStreamCarrierError('generation lost')) + await vi.waitFor(() => { expect(baselines(accept)).toBe(2) }) + expect(mock.log.streams(CONTROL)).toHaveLength(2) - bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) - - bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never) + mock.streams.push(CONTROL, BASELINE) await vi.waitFor(() => { expect(logged).toHaveBeenCalledWith( '[session-controller] control stream failed:', @@ -171,51 +101,61 @@ describe('Session Controller Client apply', () => { }) }) - it('materializes Host-addressed Agent scopes before the Session list arrives', async () => { - const bench = await mount() - const adapter = bench.ctx.typert.contexts.getClient('agent') + it('materializes Host-addressed Agent scopes before the Session list arrives', async ({ mock, start }) => { + const list = Promise.withResolvers>() + mock.remote.session.list.mockReturnValueOnce(list.promise) + const { client, sessions } = await bench(start) + const adapter = client.ctx.typert.contexts.getClient('agent') const first = adapter?.resolve(sid('agent-early')) expect(first).toBeDefined() - expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early')) + expect(sessions.scopeOf(first as Context)).toBe(sid('agent-early')) expect(adapter?.resolve(sid('agent-early'))).toBe(first) + list.resolve(ok({ items: [] })) + await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') }) }) - it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { - const bench = await mount(GENERATION) - await flush() - expect(bench.sessions.list.getSnapshot().phase).toBe('ready') + it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => { + const { client, sessions } = await bench(start) + await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') }) - bench.dispatch('api-session/added', { - sessionId: sid('agent-1'), - updatedAt: 1, - running: false, - blank: true, - }) - await flush() - const scoped = bench.sessions.scope(sid('agent-1')) - const adapter = bench.ctx.typert.contexts.getClient('agent') - expect(scoped).toBeDefined() - expect(adapter?.identity(bench.ctx)).toBeUndefined() - expect(adapter?.identity(scoped!)).toBe(sid('agent-1')) + await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true }) + await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() }) + const scoped = sessions.scope(sid('agent-1')) as Context + const adapter = client.ctx.typert.contexts.getClient('agent') + expect(adapter?.identity(client.ctx)).toBeUndefined() + expect(adapter?.identity(scoped)).toBe(sid('agent-1')) expect(adapter?.resolve(sid('agent-1'))).toBe(scoped) - await bench.fiber.dispose() - expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined() + await client.unload(SELF) + expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined() }) - it('waits for a Host generation before retrying the control stream', async () => { + it('waits for a Host generation before retrying the control stream', async ({ mock, start }) => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') - const bench = await mount() - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + const hostBack = Promise.withResolvers() + let opens = 0 + // The second $events generation stays unready until the test lets the Host answer. + mock.stream(EVENTS, (_args, stream) => { + opens += 1 + const ready = { type: 'ready', clientId: `mock-client-${String(opens)}`, host: { home: '/home/mock' } } + if (opens === 1) stream.push(ready) + else void hostBack.promise.then(() => { stream.push(ready) }) + }) + const client = await start() + await vi.waitFor(() => { expect(baselines(accept)).toBe(1) }) - bench.api.failStreams(new RemoteStreamCarrierError('offline')) - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + client.connection.reconnect() + await mock.streams.opened(EVENTS, 2) + expect(client.connection.generation.getSnapshot()).toBeUndefined() - bench.publishGeneration(GENERATION) - await flush() - expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) + mock.streams.fail(CONTROL, new RemoteStreamCarrierError('offline')) + await client.flush() + expect(baselines(accept)).toBe(1) + expect(mock.log.streams(CONTROL)).toHaveLength(1) + + hostBack.resolve(undefined) + await vi.waitFor(() => { expect(baselines(accept)).toBe(2) }) + expect(client.connection.generation.getSnapshot()).toMatchObject({ id: 2 }) }) }) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index c86546f630..b45e3c01f7 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -28,8 +28,7 @@ import { type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' -import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' -import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' +import { followSnapshot, pageThrough } from './remote/history.client.ts' const AVAILABLE_STREAM_CONNECTION = { generation: { @@ -363,11 +362,7 @@ export class FakeApiClient { if (!result.ok) return result return { ok: true, - value: { - ...result.value, - records: result.value.records - .filter(record => historyRecordLastSeq(record) <= request.throughSeq), - }, + value: pageThrough(result.value, request.throughSeq), } } @@ -388,27 +383,7 @@ export class FakeApiClient { }) if (!response.ok) throw response.error const page = response.value - const tail = page.records.at(-1) - const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail)) - yield { - type: 'snapshot', - header: { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 0, - isSeeded: false, - ...(request.address.kind === 'subagent' - ? { origin: 'subagent' as const, parentSession: request.address.parentSessionId } - : {}), - }, - cursor, - records: page.records.filter(record => historyRecordLastSeq(record) <= cursor), - hasMore: page.hasMore, - projections: page.projections ?? { asOfSeq: cursor, values: {} }, - ...request.assistantStream === true - ? { assistantStream: this.assistantStreamBaseline } - : {}, - } + yield followSnapshot(page, request, this.followCursor, this.assistantStreamBaseline) yield* stream.values } finally { stream.dispose() diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 573e4cfa7f..a6fd798f04 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -3,17 +3,23 @@ * change, reconnect re-baselining, pre-instantiation buffering, editable-text * projection, and snapshot reference stability. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' -import { Session } from '../src/client/sessions/session.ts' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { SessionManager } from '../src/client/sessions/manager.ts' -import { FakeApiClient, fakeRemote } from './fake-api.client.ts' +import { sessionBench } from './remote/bench.client.ts' +import { pushEvent, sessionWorld } from './remote/session.client.ts' +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) const SID = 'fk-q1' as SessionId +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] const rid = (id: string): RpcId => id as RpcId const iid = (id: string): MessageId => id as MessageId @@ -42,23 +48,9 @@ function queueFrame(items: QueueFixture[]): Extract { - it('projects stable ids, flat previews, and complete text', () => { - const session = makeSession() + it('projects stable ids, flat previews, and complete text', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, ])) @@ -71,10 +63,10 @@ describe('Session queue snapshot intake', () => { preview: '第一条 排队 消息', text: '第一条 排队\n消息', }, ]) - }) + }, COLD_BOOT_TIMEOUT_MS) - it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => { - const session = makeSession() + it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', @@ -101,8 +93,8 @@ describe('Session queue snapshot intake', () => { ]) }) - it('caps previews at 200 code points and preserves the full editable text', () => { - const session = makeSession() + it('caps previews at 200 code points and preserves the full editable text', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const body = '长'.repeat(201) session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) const row = session.getSnapshot().queue[0] @@ -111,8 +103,8 @@ describe('Session queue snapshot intake', () => { expect(row?.text).toBe(body) }) - it('replaces content, order, and membership from each authoritative frame', () => { - const session = makeSession() + it('replaces content, order, and membership from each authoritative frame', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([ { id: 'q-1', body: 'one' }, { id: 'q-2', body: 'two' }, @@ -133,16 +125,16 @@ describe('Session queue snapshot intake', () => { expect(session.getSnapshot().queue).toEqual([]) }) - it('keeps the queue array reference stable across unrelated snapshot swaps', () => { - const session = makeSession() + it('keeps the queue array reference stable across unrelated snapshot swaps', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) const before = session.getSnapshot().queue session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) }) - it('retains steering placement and complete content in the same authoritative snapshot', () => { - const session = makeSession() + it('retains steering placement and complete content in the same authoritative snapshot', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([ { id: 'q-next', body: 'later' }, { id: 's-now', body: 'interrupt now', placement: 'steering' }, @@ -156,8 +148,8 @@ describe('Session queue snapshot intake', () => { ]) }) - it('hands off exactly one current occurrence when live steering becomes durable', async () => { - const { api, session } = makeBench() + it('hands off exactly one current occurrence when live steering becomes durable', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const message = createUserMessage({ content: text('same message'), @@ -175,7 +167,7 @@ describe('Session queue snapshot intake', () => { data: message, } satisfies SessionEvent - await api.pushFollow(SID, { type: 'event', event: durable as never }) + await pushEvent(mock, durable) await vi.waitFor(() => { expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) }) @@ -183,14 +175,14 @@ describe('Session queue snapshot intake', () => { session.handleControlFrame(queueFrame([ { id: 's-later', body: '', placement: 'steering', message }, ])) - await api.pushFollow(SID, { type: 'event', event: durable as never }) + await pushEvent(mock, durable) await vi.waitFor(() => { expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) }) }) - it('hands off live steering when the agent claims it as a user message', async () => { - const { api, session } = makeBench() + it('hands off live steering when the agent claims it as a user message', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const message = createUserMessage({ content: text('claimed steering'), @@ -200,16 +192,13 @@ describe('Session queue snapshot intake', () => { { id: 's-claimed', body: '', placement: 'steering', message }, ])) - await api.pushFollow(SID, { - type: 'event', - event: { - seq: 0, - time: 1_700_000_000_000, - type: 'user/message', - surfaceOp: 'append', - data: message, - } as never, - }) + await pushEvent(mock, { + seq: 0, + time: 1_700_000_000_000, + type: 'user/message', + surfaceOp: 'append', + data: message, + } as never) await vi.waitFor(() => { expect(session.getSnapshot().queue).toEqual([]) @@ -218,9 +207,8 @@ describe('Session queue snapshot intake', () => { }) describe('queue operation transport', () => { - it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api)) + it('addresses the session.updateQueue RPC without optimistic local mutation', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) const before = session.getSnapshot().queue @@ -228,7 +216,7 @@ describe('queue operation transport', () => { .resolves.toEqual({ ok: true, value: { accepted: true } }) await expect(session.updateQueue(iid('q-op'), { kind: 'steer' })) .resolves.toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.updateQueue')).toEqual([ + expect(mock.log.requests('session/updateQueue')).toEqual([ { sessionId: SID, itemId: 'q-op', @@ -245,8 +233,8 @@ describe('queue operation transport', () => { }) describe('queue reconnect semantics', () => { - it('a control baseline clears stale state before a fresh update lands', () => { - const session = makeSession() + it('a control baseline clears stale state before a fresh update lands', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) session.replaceControl([]) expect(session.getSnapshot().queue).toEqual([]) @@ -254,16 +242,16 @@ describe('queue reconnect semantics', () => { expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { - const session = makeSession() + it('resync does not clear a baseline that raced ahead of the host connection signal', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) await session.resync() expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) }) - it('running-status changes never guess at queue retirement', () => { - const session = makeSession() + it('running-status changes never guess at queue retirement', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) session.handleRunning(true) session.handleRunning(false) @@ -272,15 +260,19 @@ describe('queue reconnect semantics', () => { }) describe('manager buffering of queue snapshots', () => { - it('replays only the latest snapshot for an uninstantiated session', () => { - const manager = makeManager() + it('replays only the latest snapshot for an uninstantiated session', async ({ mock, start }) => { + mock.load(sessionWorld) + const { ctx: { remote } } = await start() + const manager = new SessionManager(remote) manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('a control baseline replaces the prior queue', () => { - const manager = makeManager() + it('a control baseline replaces the prior queue', async ({ mock, start }) => { + mock.load(sessionWorld) + const { ctx: { remote } } = await start() + const manager = new SessionManager(remote) manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items manager.handleControlFrame({ diff --git a/packages/api/session-controller/tests/remote/bench.client.ts b/packages/api/session-controller/tests/remote/bench.client.ts new file mode 100644 index 0000000000..6e0f10d569 --- /dev/null +++ b/packages/api/session-controller/tests/remote/bench.client.ts @@ -0,0 +1,28 @@ +/** Session objects owned by a client test's explicitly started Gateway assembly. */ +import { onTestFinished } from 'vitest' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { TestClient } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { Session, type SessionOptions } from '../../src/client/sessions/session.ts' +import { sessionWorld } from './session.client.ts' + +/** + * Create a Session after installing its default Remote responses. + * @param mock - this case's Remote responses. + * @param start - the fixture's lazy client startup. + * @param sessionId - Session identity. + * @param options - addressed child and parent availability, when applicable. + * @returns the Session, disposed at the end of the case. + */ +export async function sessionBench( + mock: RemoteMock, + start: () => Promise, + sessionId: SessionId, + options: SessionOptions = {}, +): Promise { + mock.load(sessionWorld) + const client = await start() + const session = new Session(sessionId, client.ctx.remote, options) + onTestFinished(() => session.dispose()) + return session +} diff --git a/packages/api/session-controller/tests/remote/history.client.ts b/packages/api/session-controller/tests/remote/history.client.ts new file mode 100644 index 0000000000..3a194f4e99 --- /dev/null +++ b/packages/api/session-controller/tests/remote/history.client.ts @@ -0,0 +1,48 @@ +/** Pure history responses shared by the local Remote fake and assembled Session tests. */ +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' +import type { + SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionProjectionBaseline, +} from '../../src/types.ts' +import { historyRecordLastSeq } from '../../src/client/sessions/history-records.ts' + +/** + * Cut a history page at the Host cursor, preserving its other fields. + * @param page - scripted history page. + * @param throughSeq - inclusive final sequence. + * @returns the page without records beyond the cursor. + */ +export function pageThrough(page: SessionPage, throughSeq: number): SessionPage { + return { ...page, records: page.records.filter(record => historyRecordLastSeq(record) <= throughSeq) } +} + +/** + * Build the opening follow frame for a Session or addressed child. + * @param page - history and optional projection baseline. + * @param request - addressed follow request. + * @param cursor - opening cursor; defaults to the history tail, or -1 when empty. + * @param assistantStream - baseline included only when the request opts in. + * @returns the opening frame. + */ +export function followSnapshot( + page: SessionPage & { readonly projections?: SessionProjectionBaseline }, + request: SessionFollowRequest, + cursor = page.records.length === 0 ? -1 : historyRecordLastSeq(page.records.at(-1)!), + assistantStream: SessionAssistantStreamBaseline = { revision: 0 }, +): Extract { + const { address } = request + return { + type: 'snapshot', + header: { + version: SESSION_FORMAT_VERSION, + id: address.kind === 'session' ? address.sessionId : address.childSessionId, + createdAt: 0, + isSeeded: false, + ...(address.kind === 'subagent' ? { origin: 'subagent', parentSession: address.parentSessionId } : {}), + }, + cursor, + records: pageThrough(page, cursor).records, + hasMore: page.hasMore, + projections: page.projections ?? { asOfSeq: cursor, values: {} }, + ...(request.assistantStream === true ? { assistantStream } : {}), + } +} diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts new file mode 100644 index 0000000000..d2e0b3c135 --- /dev/null +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -0,0 +1,188 @@ +/** + * The Remote side of one Session under test: default answers for every + * `session/*` and `subagents/*` endpoint a `Session` calls, builders for + * the two history-shaped answers, the `session/follow` opening snapshot and + * the `session/page` page, both derived from event lists the way the Host + * derives them from its log, and builders for the `session/control` queue + * frame and the attachment references the Host's log carries. + */ +import { AttachmentId, type FileAttachmentRef, type ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { ok, type RemoteMock, type RemoteTable, type StreamScript, type UnaryRuleFn } from '@deepseek-ai/dsh-remote-mock' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { + SessionControlFrame, SessionFollowFrame, SessionFollowRequest, SessionPage, SessionPageRequest, + SessionQueuedItem, SessionRequestId, +} from '../../src/types.ts' +import { entries, historyValue } from '../event-script.client.ts' +import { followSnapshot, pageThrough } from './history.client.ts' + +export { followSnapshot } from './history.client.ts' + +/** Endpoints a Session opens and calls for its history. */ +export const FOLLOW = 'session/follow' +export const PAGE = 'session/page' + +/** A history answer as the Host returns it, possibly still pending. */ +export type HistoryAnswer = RemoteResult | Promise> +/** A history answer, or a function of the request that produces one. */ +export type HistorySource = HistoryAnswer | ((request: Request) => HistoryAnswer) + +/** + * The failure branch of a Remote result. + * @param error - the owner-declared failure. + * @returns the result. + */ +export function err(error: RemoteFailure): RemoteResult { + return { ok: false, error } +} + +/** + * The Host's history answer for `events`. + * @param events - events in seq order. + * @param hasMore - whether older history exists. + * @returns the success result. + */ +export function history(events: readonly SessionEvent[], hasMore = false): RemoteResult { + return ok(historyValue(events, hasMore)) +} + +/** + * One live event frame of a follow stream. + * @param event - the event. + * @returns the frame. + */ +export function frame(event: SessionEvent): SessionFollowFrame { + return entries([event])[0]! +} + +/** + * Deliver one live event to every open follow stream and wait until the client has consumed it. + * @param mock - the mock holding the streams. + * @param event - the event. + */ +export async function pushEvent(mock: RemoteMock, event: SessionEvent): Promise { + mock.streams.push(FOLLOW, frame(event)) + await mock.streams.drained(FOLLOW) +} + +/** + * `session/follow` script: the opening snapshot built from the history answer, + * then open for pushes. A failed answer fails the stream with its error; a + * rejected one fails it with the rejection. + * @param history - history answer or a function of the follow request. + * @param options - `cursor` overrides the snapshot cursor. + * @returns the script. + */ +export function followScript(history: HistorySource, options: { cursor?: number } = {}): StreamScript { + return async ([request], stream) => { + const follow = request as SessionFollowRequest + const result = await answer(history, follow) + if (!result.ok) { + stream.fail(result.error) + return + } + stream.push(followSnapshot(result.value, follow, options.cursor)) + } +} + +/** + * `session/page` rule: the history answer cut at the request's `throughSeq`. + * @param history - history answer or a function of the page request. + * @returns the rule. + */ +export function pageRule( + history: HistorySource, +): UnaryRuleFn>> { + return async (page) => { + const result = await answer(history, page) + if (!result.ok) return result + return ok(pageThrough(result.value, page.throughSeq)) + } +} + +function answer(history: HistorySource, request: Request): HistoryAnswer { + return typeof history === 'function' ? history(request) : history +} + +/** + * An image attachment reference as the Host's durable log carries it. + * @param id - opaque attachment id. + * @returns the reference. + */ +export function imageRef(id: string): ImageAttachmentRef { + return { attachmentId: AttachmentId(id), mediaType: 'image/png', bytes: 1, width: 2, height: 2 } +} + +/** + * A file attachment reference as the Host's durable log carries it. + * @param id - opaque attachment id. + * @param name - display filename. + * @returns the reference. + */ +export function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { + return { attachmentId: AttachmentId(id), name, bytes: 3 } +} + +/** One pending inbox occurrence of a queue frame; the message id defaults to the item id. */ +export interface QueueItemFixture { + readonly id: string + readonly placement?: SessionQueuedItem['placement'] + /** Prompt identity the Host copied from the queued message's user source. */ + readonly rpcId?: SessionRequestId + /** Queued message content; ignored when `message` is given. */ + readonly content?: readonly ContentBlock[] + /** The queued message itself, for a test that also lands it durably (the queue mirror correlates by `id`). */ + readonly message?: { readonly id: MessageId; readonly content: readonly ContentBlock[] } +} + +/** + * One pending inbox occurrence as the Host lists it. + * @param fixture - item fields. + * @returns the item. + */ +export function queueItem(fixture: QueueItemFixture): SessionQueuedItem { + const message = fixture.message ?? { id: fixture.id as MessageId, content: fixture.content ?? [] } + return { + id: fixture.id as MessageId, + placement: fixture.placement ?? 'queued', + ...(fixture.rpcId === undefined ? {} : { rpcId: fixture.rpcId }), + // The Host serializes queued content to JSON; the blocks cross unchanged. + message: { id: message.id, content: message.content as unknown as SessionQueuedItem['message']['content'] }, + } +} + +/** + * One authoritative queue frame of the `session/control` stream. + * @param sessionId - the addressed Session. + * @param items - the complete pending queue. + * @returns the frame. + */ +export function queueFrame( + sessionId: SessionId, + items: readonly QueueItemFixture[], +): Extract { + return { type: 'queue', sessionId, items: items.map(queueItem) } +} + +/** Default answers: every command accepted, an empty history, one attachment of one zero byte. */ +export const sessionWorld: RemoteTable = { + unary: { + 'session/prompt': ok({ accepted: true }), + 'session/cancel': ok({ accepted: true }), + 'session/updateQueue': ok({ accepted: true }), + 'session/rename': ok({ title: 'fk-renamed', seq: 0 }), + 'session/attachment': ok({ + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: 'AA==', + }), + 'session/page': pageRule(ok({ records: [], hasMore: false })), + 'subagents/prompt': ok({ messageId: 'fake-message' }), + 'subagents/interruptByParent': ok({ accepted: true }), + }, + stream: { + 'session/follow': followScript(ok({ records: [], hasMore: false })), + }, +} diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 46cfe1c049..b88f869367 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -1,40 +1,34 @@ -/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */ +/** + * Local submission echoes: synchronous insertion, observed/failed retirement, + * and settlement callbacks. Prompts and the follow stream cross the assembled + * Gateway client and are answered by endpoint name. + */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { Session } from '../src/client/sessions/session.ts' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' -import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' -import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' -import { historyValue } from './event-script.client.ts' +import type { SessionRequestId } from '../src/types.ts' +import { sessionBench } from './remote/bench.client.ts' +import { + FOLLOW, err, fileRef, followScript, history, imageRef, pushEvent, queueFrame, +} from './remote/session.client.ts' +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) const SID = 'fk-s1' as SessionId +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 afterEach(() => { vi.unstubAllGlobals() }) -function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, fakeRemote(api)) } -} - -function imageRef(id: string): ImageAttachmentRef { - return { - attachmentId: id, - mediaType: 'image/png', - bytes: 1, - width: 2, - height: 2, - } as unknown as ImageAttachmentRef -} - -function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { - return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef -} - type AttachmentRef = ImageAttachmentRef | FileAttachmentRef function attachmentBlock(attachment: AttachmentRef) { @@ -60,16 +54,9 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly At } as unknown as SessionEvent } -function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem { - return { - id: 'm-queued' as SessionQueuedItem['id'], - placement: 'queued', - rpcId, - message: { - id: 'm-queued' as SessionQueuedItem['id'], - content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'], - }, - } +/** The Host's queue holding one occurrence of the prompt `rpcId`. */ +function queuedFrame(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []) { + return queueFrame(SID, [{ id: 'm-queued', rpcId, content: refs.map(attachmentBlock) }]) } /** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ @@ -78,8 +65,8 @@ function settleFrames(): Promise { } describe('beginSubmission', () => { - it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => { - const { session } = makeSession() + it('inserts the echo synchronously and flips the engaging edge before any prompt call', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) const handle = session.beginSubmission({ mode: 'queue', @@ -97,10 +84,11 @@ describe('beginSubmission', () => { type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, }], }]) - }) + expect(mock.log.requests()).toEqual([]) + }, COLD_BOOT_TIMEOUT_MS) - it('derives and captures the echo placement from running state and delivery mode', () => { - const { session } = makeSession() + it('derives and captures the echo placement from running state and delivery mode', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] }) session.handleRunning(true) session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] }) @@ -113,8 +101,8 @@ describe('beginSubmission', () => { ]) }) - it('abandon retires the echo as failed exactly once', () => { - const { session } = makeSession() + it('abandon retires the echo as failed exactly once', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ mode: 'queue', @@ -130,9 +118,9 @@ describe('beginSubmission', () => { }) describe('prompt-coupled retirement', () => { - it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) + it('a rejected identified prompt retires its echo immediately alongside promptError', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ mode: 'queue', @@ -143,20 +131,20 @@ describe('prompt-coupled retirement', () => { const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) expect(result.ok).toBe(false) expect(session.getSnapshot().pendingSubmissions).toEqual([]) - expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } }) expect(retirements).toEqual([{ reason: 'failed' }]) }) - it('sends the echo identity as the prompt requestId', async () => { - const { api, session } = makeSession() + it('sends the echo identity as the prompt requestId', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) - expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) + expect(mock.log.requests('session/prompt')).toMatchObject([{ requestId: handle.requestId, sessionId: SID }]) }) - it('an unidentified prompt failure leaves registered echoes alone', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) + it('an unidentified prompt failure leaves registered echoes alone', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) @@ -164,9 +152,8 @@ describe('prompt-coupled retirement', () => { }) describe('observed retirement', () => { - it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ @@ -176,7 +163,7 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs)) // Synchronously after the append the echo is still in the snapshot; the // render-time dedupe owns the overlap frame. expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) @@ -185,8 +172,8 @@ describe('observed retirement', () => { expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) }) - it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { - const { session } = makeSession() + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const retirements: PendingSubmissionRetirement[] = [] session.handleRunning(true) const handle = session.beginSubmission({ @@ -196,7 +183,7 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] - session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + session.handleControlFrame(queuedFrame(handle.requestId, refs)) await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) @@ -204,9 +191,8 @@ describe('observed retirement', () => { expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) }) - it('retires a mixed echo with durable references in original selection order', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('retires a mixed echo with durable references in original selection order', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const file = fileRef('file-1') @@ -221,23 +207,22 @@ describe('observed retirement', () => { onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('image-1'), file, imageRef('image-2')] - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId, refs)) await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) }) - it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { - const { api, session } = makeSession() + it('a full-window install (reconnect resync) retires echoes observed in the window', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] }) - api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)]))) + mock.stream(FOLLOW, followScript(history([promptEvent(SessionSeq(12), handle.requestId)]))) await session.open() await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) }) - it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ @@ -246,15 +231,14 @@ describe('observed retirement', () => { attachments: [], onRetire: retirement => retirements.push(retirement), }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId)) handle.abandon() await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) }) - it('retires once when the queue and durable event report the same request id', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('retires once when the queue and durable event report the same request id', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ @@ -263,28 +247,23 @@ describe('observed retirement', () => { attachments: [], onRetire: retirement => retirements.push(retirement), }) - session.handleControlFrame({ - type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, [])], - }) - await api.pushFollow(SID, { - type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never, - }) + session.handleControlFrame(queuedFrame(handle.requestId)) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId)) await settleFrames() expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) expect(session.getSnapshot().pendingSubmissions).toEqual([]) }) - it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => { + it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.open() const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { frames.push(fn) return frames.length }) - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) - await session.open() const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), handle.requestId)) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) frames[0]?.(0) @@ -293,9 +272,8 @@ describe('observed retirement', () => { }) describe('disposal', () => { - it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(ok(historyValue([]))) + it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.open() const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] const observed = session.beginSubmission({ @@ -310,7 +288,7 @@ describe('disposal', () => { attachments: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), }) - await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never }) + await pushEvent(mock, promptEvent(SessionSeq(0), observed.requestId)) await session.dispose() await settleFrames() expect(retirements).toEqual([ diff --git a/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts new file mode 100644 index 0000000000..a6e7a274aa --- /dev/null +++ b/packages/api/session-controller/tests/session-remote-rejection.client.spec.ts @@ -0,0 +1,44 @@ +/** + * A rejection from the Remote face, which the generated client raises only for + * an assembly fault (wrong arity, an unmounted method, a withdrawn + * contribution), propagates out of the Session command unchanged and records no + * prompt error. Carrier failures never reject: they arrive as folded results, + * covered by session.client.spec.ts. + */ +import { describe, expect, it } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { Session } from '../src/client/sessions/session.ts' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' + +const SID = 'fk-s1' as SessionId +const PARENT = 'fk-parent' as SessionId + +/** Every command rejects with `message`; nothing else on the face is reachable from prompt or cancel. */ +function rejecting(message: string): SessionRemotes { + const reject = (): Promise => Promise.reject(new Error(message)) + return { + $stream: () => { throw new Error('unused') }, + commands: { execute: reject }, + session: { prompt: reject, cancel: reject }, + subagents: { list: reject, prompt: reject, interruptByParent: reject }, + } as unknown as SessionRemotes +} + +describe('Session over a rejecting Remote face', () => { + it('propagates the rejection from prompt and cancel and records no prompt error', async () => { + const session = new Session(SID, rejecting('assembly fault')) + await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') + await expect(session.cancel()).rejects.toThrow('assembly fault') + expect(session.getSnapshot().promptError).toBeNull() + }) + + it('propagates the rejection from a subagent continuation prompt and interrupt', async () => { + const session = new Session(SID, rejecting('assembly fault'), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('assembly fault') + await expect(session.cancel()).rejects.toThrow('assembly fault') + expect(session.getSnapshot().promptError).toBeNull() + }) +}) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index c5a62a27df..9607c670f0 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -1,36 +1,35 @@ -/** Session object lifecycle, event-window transport, commands, and resync behavior. */ +/** + * Session object lifecycle, event-window transport, commands, and resync + * behavior, driven through the assembled client: every Remote call a Session + * makes crosses the roster's own Connection through the tier's `remote.` + * proxies and is answered by endpoint name. + */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { JUMP_PAGE_MESSAGES, Session, type SessionOptions } from '../src/client/sessions/session.ts' -import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { JUMP_PAGE_MESSAGES, type Session } from '../src/client/sessions/session.ts' +import { SessionEventStream } from '../src/client/transport.ts' +import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' +import { sessionBench } from './remote/bench.client.ts' +import { + FOLLOW, PAGE, err, followScript, followSnapshot, frame, history, pageRule, pushEvent, +} from './remote/session.client.ts' +/** A Session talks through the Gateway client; its dependency cone is the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const it = createClientTest({ roster: API_ROSTER }) const SID = 'fk-s1' as SessionId const PARENT = 'fk-parent' as SessionId - -afterEach(() => { - vi.unstubAllGlobals() -}) -function makeSession( - api = new FakeApiClient(), - options: SessionOptions = {}, -): { api: FakeApiClient; session: Session } { - return { api, session: new Session(SID, fakeRemote(api), options) } -} - -function follow( - api: FakeApiClient, - event: SessionEvent, -): Promise { - return api.pushFollow(SID, { - type: 'event', - event: event as never, - }) -} +const ADDRESS = { kind: 'session', sessionId: SID } as const +const TIME_ZONE = new Intl.DateTimeFormat().resolvedOptions().timeZone +/** The first client boot pays the cold module transform of the api cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 function windowEntries(session: Session) { return session.eventSource.getSnapshot().entries @@ -40,23 +39,19 @@ function eventSeqs(session: Session): number[] { return windowEntries(session).map(entry => entry.event.seq) } -function histResponse(events: SessionEvent[], hasMore = false) { - return Promise.resolve(ok(historyValue(events, hasMore))) -} - describe('Session open', () => { - it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { - const { session } = makeSession() + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false }) session.handleRunning(true) expect(session.getSnapshot()).toMatchObject({ blank: false, running: true }) - }) + }, COLD_BOOT_TIMEOUT_MS) - it('installs the tail page: cold → loading → open with window and nodes in place', async () => { - const { api, session } = makeSession() + it('installs the tail page: cold → loading → open with window and nodes in place', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const page = plainTurn(SessionSeq(10), 3, '问', '答') - api.onHistory = () => histResponse(page, true) + mock.stream(FOLLOW, followScript(history(page, true))) expect(session.getSnapshot().openState).toBe('cold') const opening = session.open() expect(session.getSnapshot().openState).toBe('loading') @@ -68,103 +63,96 @@ describe('Session open', () => { expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' }) }) - it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => { - const { api, session } = makeSession() + it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await Promise.all([session.open(), session.open()]) await session.open() - expect(api.callsOf('session.follow')).toHaveLength(1) - expect(api.callsOf('session.history')).toEqual([]) + expect(mock.log.requests(FOLLOW)).toHaveLength(1) + expect(mock.log.requests(PAGE)).toEqual([]) }) - it('lands an error result in openState=error with the Remote failure kept', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.resolve(err(new RemoteError('session/not-found', 'gone', { sessionId: SID }))) + it('lands an error result in openState=error with the Remote failure kept', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(err(new RemoteError('session/not-found', 'gone', { sessionId: SID })))) await session.open() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') expect(snapshot.openError?.code).toBe('session/not-found') }) - it('lands exhausted carrier retries in openState=error as gateway/internal', async () => { - const { api, session } = makeSession() + it('lands exhausted carrier retries in openState=error as gateway/internal', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) // Two consecutive carrier losses before any opening is accepted exhaust the // Gateway's retry budget; the escaping failure crosses the stream boundary marked. - api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down')) + mock.stream(FOLLOW, followScript(() => Promise.reject(new RemoteStreamCarrierError('history carrier down')))) await session.open() expect(session.getSnapshot().openState).toBe('error') expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'history carrier down', }) - expect(api.followStarts).toHaveLength(2) + expect(mock.log.requests(FOLLOW)).toHaveLength(2) }) - it('lands a Gateway-marked stream failure in openState=error', async () => { - const { api, session } = makeSession() - api.onHistory = () => Promise.reject(new Error('socket died')) + it('lands a Gateway-marked stream failure in openState=error', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(() => Promise.reject(new Error('socket died')))) await session.open() expect(session.getSnapshot().openState).toBe('error') expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' }) }) - it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { - const { api, session } = makeSession() - const gate = deferred>>() - api.onHistory = () => gate.promise - const opening = session.open() - // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail. + it('stitches live frames landing right behind the opening snapshot, dropping the page overlap', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const gate = Promise.withResolvers() const page = plainTurn(SessionSeq(10), 0, '早', '安') - const deliveries = [ - follow(api, ev.turnStart(SessionSeq(15), 1)), - follow(api, ev.user(SessionSeq(16), '插进来的')), - ] - gate.resolve(ok({ - records: entries(page) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await Promise.all([opening, ...deliveries]) - const seqs = eventSeqs(session) + mock.stream(FOLLOW, async ([request], stream) => { + stream.push(followSnapshot(await gate.promise, request as SessionFollowRequest)) + // Two live frames follow the snapshot before the opening settles; seq 15 overlaps its tail. + stream.push(frame(ev.turnStart(SessionSeq(15), 1))) + stream.push(frame(ev.user(SessionSeq(16), '插进来的'))) + }) + const opening = session.open() + gate.resolve(historyValue(page)) + await opening + await mock.streams.drained(FOLLOW) // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. - expect(seqs).toEqual([10, 11, 12, 13, 14, 15, 16]) + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16]) }) }) - describe('live event path', () => { - async function opened(events: SessionEvent[] = plainTurn(SessionSeq(0), 0, 'a', 'b')) { - const { api, session } = makeSession() - api.onHistory = () => histResponse(events) + async function opened(mock: RemoteMock, start: () => Promise, events: SessionEvent[] = plainTurn(SessionSeq(0), 0, 'a', 'b')) { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(events))) await session.open() - return { api, session } + return session } - it('drops replayed frames at or below the window tail', async () => { - const { api, session } = await opened() + it('drops replayed frames at or below the window tail', async ({ mock, start }) => { + const session = await opened(mock, start) const before = session.eventSource.getSnapshot() - await follow(api, ev.user(SessionSeq(3), '重放')) + await pushEvent(mock, ev.user(SessionSeq(3), '重放')) expect(session.eventSource.getSnapshot()).toBe(before) }) - it('keeps the authoritative host blank bit across unrelated log events', async () => { - const { api, session } = await opened([]) + it('keeps the authoritative host blank bit across unrelated log events', async ({ mock, start }) => { + const session = await opened(mock, start, []) session.handleBlank(true) - await Promise.all([ - follow(api, ev.commandRun(SessionSeq(0), 'cmd-perm', 'permission', ' danger-full-access')), - follow(api, ev.commandDone(SessionSeq(1), 'cmd-perm', 'success', 'preset danger-full-access')), - ]) + await pushEvent(mock, ev.commandRun(SessionSeq(0), 'cmd-perm', 'permission', ' danger-full-access')) + await pushEvent(mock, ev.commandDone(SessionSeq(1), 'cmd-perm', 'success', 'preset danger-full-access')) const snapshot = session.getSnapshot() expect(eventSeqs(session)).toEqual([0, 1]) expect(snapshot.blank).toBe(true) }) - it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { - const { api, session } = await opened(plainTurn(SessionSeq(0), 0, 'a', 'b')) // tail seq = 5 + it('repairs a seq gap by repulling the tail page instead of appending a hole', async ({ mock, start }) => { + const session = await opened(mock, start, plainTurn(SessionSeq(0), 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')] - api.onHistory = () => histResponse(repaired) - // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. - await follow(api, ev.assistant(SessionSeq(9), 1, 'd')) + mock.remote.session.page.mockImplementation(pageRule(history(repaired))) + // seq 9 with tail 5 → gap; the event detours to the buffer and one page refetch fires. + await pushEvent(mock, ev.assistant(SessionSeq(9), 1, 'd')) await vi.waitFor(() => { - expect(api.callsOf('session.history')).toHaveLength(1) + expect(mock.log.requests(PAGE)).toHaveLength(1) }) await vi.waitFor(() => { expect(eventSeqs(session)).toEqual( @@ -175,31 +163,30 @@ describe('live event path', () => { }) describe('paging', () => { - it('prepends an older page and keeps seq continuity', async () => { + it('prepends an older page and keeps seq continuity', async ({ mock, start }) => { const older = plainTurn(SessionSeq(0), 0, '旧问', '旧答') const newer = plainTurn(SessionSeq(6), 1, '新问', '新答') - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(newer, true) - : histResponse(older, false) + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newer, true))) + mock.remote.session.page.mockImplementation(pageRule(history(older))) await session.open() await session.loadOlder() const snapshot = session.getSnapshot() - expect(api.callsOf('session.follow')).toHaveLength(1) - expect(api.callsOf('session.history')).toMatchObject([ - { sessionId: SID, throughSeq: 11, beforeSeq: 6 }, + expect(mock.log.requests(FOLLOW)).toHaveLength(1) + expect(mock.log.requests(PAGE)).toMatchObject([ + { address: ADDRESS, throughSeq: 11, beforeSeq: 6 }, ]) expect(snapshot.hasMore).toBe(false) expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq)) }) - it('installs a page without interpreting business replacement metadata', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse([ + it('installs a page without interpreting business replacement metadata', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history([ ev.compactSummary(SessionSeq(80), '窗外范围的摘要', SessionSeq(3), SessionSeq(40)), ev.compactCheckpoint(SessionSeq(81), SessionSeq(80), SessionSeq(3), SessionSeq(40)), ev.user(SessionSeq(82), '压缩后的新问题'), - ], true) + ], true))) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { await session.open() @@ -212,11 +199,10 @@ describe('paging', () => { } }) - it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(SessionSeq(10), 1, '新', '页'), true) - : histResponse(plainTurn(SessionSeq(0), 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole + it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(10), 1, '新', '页'), true))) + mock.remote.session.page.mockImplementation(pageRule(history(plainTurn(SessionSeq(0), 0, '断', '层'), true))) // tail seq 5, but baseSeq is 10 → hole const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) try { await session.open() @@ -230,174 +216,164 @@ describe('paging', () => { } }) - it('loadThrough pages repeatedly until the window covers the target seq', async () => { + it('loadThrough pages repeatedly until the window covers the target seq', async ({ mock, start }) => { const oldest = plainTurn(SessionSeq(0), 0, '最旧问', '最旧答') const middle = plainTurn(SessionSeq(6), 1, '中问', '中答') const newest = plainTurn(SessionSeq(12), 2, '新问', '新答') - const { api, session } = makeSession() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) return histResponse(newest, true) - return payload.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false) - } + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(newest, true))) await session.open() - const gate = deferred>>() - api.onHistory = (payload) => { - api.onHistory = payload2 => payload2.beforeSeq === 12 ? histResponse(middle, true) : histResponse(oldest, false) - void payload - return gate.promise - } + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(request => (request.beforeSeq === 12 ? history(middle, true) : history(oldest)))) + mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) expect(session.getSnapshot().loadingOlder).toBe(true) - gate.resolve(ok(historyValue(middle, true))) + gate.resolve(history(middle, true)) await jump const snapshot = session.getSnapshot() expect(snapshot.loadingOlder).toBe(false) expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq)) - expect(api.callsOf('session.history')).toMatchObject([ + expect(mock.log.requests(PAGE)).toMatchObject([ { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES }, { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES }, ]) }) - it('loadThrough is a no-op when the window already covers the target or the session is not open', async () => { - const { api, session } = makeSession() + it('loadThrough is a no-op when the window already covers the target or the session is not open', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await session.loadThrough(SessionSeq(0)) // cold: no-op - expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) + expect(mock.log.requests()).toEqual([]) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const calls = api.calls.length + const sent = mock.log.requests().length await session.loadThrough(SessionSeq(6)) // baseSeq is already 6 await session.loadThrough(SessionSeq(9)) // inside the window - expect(api.calls.length).toBe(calls) + expect(mock.log.requests()).toHaveLength(sent) }) - it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async () => { + it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async ({ mock, start }) => { const oldest = plainTurn(SessionSeq(0), 0, 'a', 'b') const middle = plainTurn(SessionSeq(6), 1, 'c', 'd') - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true) + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() - const gate = deferred>>() - api.onHistory = () => { - api.onHistory = () => histResponse(oldest, false) - return gate.promise - } + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(history(oldest))) + mock.remote.session.page.mockReturnValueOnce(gate.promise) const first = session.loadThrough(SessionSeq(6)) const second = session.loadThrough(SessionSeq(0)) - gate.resolve(ok(historyValue(middle, true))) + gate.resolve(history(middle, true)) await Promise.all([first, second]) expect(eventSeqs(session)).toEqual([ ...[...oldest, ...middle].map(event => event.seq), 12, 13, 14, 15, 16, 17, ]) - expect(api.callsOf('session.history')).toHaveLength(2) + expect(mock.log.requests(PAGE)).toHaveLength(2) }) - it('loadThrough refused by a busy pager leaves no target behind for later jumps', async () => { + it('loadThrough refused by a busy pager leaves no target behind for later jumps', async ({ mock, start }) => { const middle = plainTurn(SessionSeq(6), 1, 'c', 'd') - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'e', 'f'), true) + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'e', 'f'), true))) await session.open() // A plain single-page pull holds the busy flag while the jump is refused. - const gate = deferred>>() - api.onHistory = () => gate.promise + const gate = Promise.withResolvers>() + mock.remote.session.page.mockReturnValueOnce(gate.promise) const older = session.loadOlder() await session.loadThrough(SessionSeq(0)) // refused: must not park seq 0 anywhere - gate.resolve(ok(historyValue(middle, true))) + gate.resolve(history(middle, true)) await older // A later jump to a nearer seq pages exactly to it — a leaked 0 target // would keep pulling three-event pages all the way to the head. - api.onHistory = (payload) => { - const start = ((payload as { beforeSeq?: number }).beforeSeq ?? 0) - 3 - return histResponse( + mock.remote.session.page.mockImplementation(pageRule((request) => { + const start = (request.beforeSeq ?? 0) - 3 + return history( [ev.user(SessionSeq(start), `u${String(start)}`), ev.user(SessionSeq(start + 1), `u${String(start + 1)}`), ev.user(SessionSeq(start + 2), `u${String(start + 2)}`)], start > 0, ) - } + })) await session.loadThrough(SessionSeq(4)) // Covered at seq 3 (≤ 4) after one page; a leaked 0 target would add a // third call at beforeSeq 3 and pull the head to 0. - expect(api.callsOf('session.history').map(call => (call as { beforeSeq?: number }).beforeSeq)) - .toEqual([12, 6]) + expect((mock.log.requests(PAGE) as SessionPageRequest[]).map(request => request.beforeSeq)).toEqual([12, 6]) expect(eventSeqs(session)[0]).toBe(3) }) - it('loadThrough stops paging when the event stream generation moves mid-loop', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) + it('loadThrough stops paging when the event stream generation moves mid-loop', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - const gate = deferred>>() - api.onHistory = () => gate.promise + const gate = Promise.withResolvers>() + mock.remote.session.page.mockReturnValueOnce(gate.promise) const jump = session.loadThrough(SessionSeq(0)) - // The address is rebuilt while the first page is in flight. - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) + // The address is rebuilt while the first page is in flight; the same follow script serves the new generation. const rebuilt = session.resync() - gate.resolve(ok(historyValue(plainTurn(SessionSeq(6), 1, 'c', 'd'), true))) + gate.resolve(history(plainTurn(SessionSeq(6), 1, 'c', 'd'), true)) await jump await rebuilt // The stale loop must not page the new generation toward its old target: - // history calls are the gated page and the resync tail only. - expect(api.callsOf('session.history')).toHaveLength(1) + // the gated page is the only page call, and the resync opened a second follow. + expect(mock.log.requests(PAGE)).toHaveLength(1) + expect(mock.log.requests(FOLLOW)).toHaveLength(2) expect(session.getSnapshot().loadingOlder).toBe(false) }) - it('loadThrough stops on a page that makes no progress instead of looping', async () => { - const { api, session } = makeSession() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) - : histResponse([], true) // empty page still claiming more history + it('loadThrough stops on a page that makes no progress instead of looping', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) + mock.remote.session.page.mockImplementation(pageRule(history([], true))) // empty page still claiming more history await session.open() await session.loadThrough(SessionSeq(0)) expect(session.getSnapshot().loadingOlder).toBe(false) - expect(api.callsOf('session.history')).toHaveLength(1) + expect(mock.log.requests(PAGE)).toHaveLength(1) }) - it('loadThrough fails soft on a thrown page and clears its busy state', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(12), 2, 'x', 'y'), true) + it('loadThrough fails soft on a page failure and clears its busy state: a folded carrier throw quietly, a local throw with console.error', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(12), 2, 'x', 'y'), true))) await session.open() - api.onHistory = () => Promise.reject(new Error('page wire down')) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const prepend = vi.spyOn(SessionEventStream.prototype, 'prepend') try { + mock.remote.session.page.mockImplementation(() => Promise.reject(new Error('page wire down'))) await session.loadThrough(SessionSeq(0)) - expect(errorSpy).toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() // the proxy folds the throw into a Remote failure, as the generated client would + expect(session.getSnapshot().loadingOlder).toBe(false) + prepend.mockRejectedValueOnce(new Error('local fault')) + await session.loadThrough(SessionSeq(0)) + expect(errorSpy).toHaveBeenCalledWith('[session-controller] loadThrough failed:', expect.any(Error)) expect(session.getSnapshot().loadingOlder).toBe(false) } finally { + prepend.mockRestore() errorSpy.mockRestore() } }) - it('ignores loadOlder while one is in flight (single request)', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) + it('ignores loadOlder while one is in flight (single request)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() - const gate = deferred>>() - api.onHistory = () => gate.promise + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(gate.promise)) const first = session.loadOlder() const second = session.loadOlder() - gate.resolve(ok({ - records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) + gate.resolve(history(plainTurn(SessionSeq(0), 0, 'a', 'b'))) await Promise.all([first, second]) - expect(api.callsOf('session.follow')).toHaveLength(1) - expect(api.callsOf('session.history')).toHaveLength(1) + expect(mock.log.requests(FOLLOW)).toHaveLength(1) + expect(mock.log.requests(PAGE)).toHaveLength(1) }) }) describe('prompt and cancel errors', () => { - it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + const CHILD = { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' } as const + + it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer') @@ -406,54 +382,38 @@ describe('prompt and cancel errors', () => { expect(prompted).toEqual({ ok: true, value: { accepted: true } }) expect(steered).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.follow')).toEqual([ - { - address: { - kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', - }, - assistantStream: true, - maxMessages: 50, - }, + expect(mock.log.requests(FOLLOW)).toEqual([ + { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 50 }, ]) - expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('subagents.prompt')).toEqual([ + expect(mock.log.requests(PAGE)).toEqual([]) + // The prompt mode crosses the wire as the request's delivery. + expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - parentSessionId: PARENT, childSessionId: SID, - mode: 'continuable', + ...CHILD, delivery: 'queue', content: [{ type: 'text', text: '继续' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }, { requestId: expect.any(String) as unknown as string, - parentSessionId: PARENT, childSessionId: SID, - mode: 'continuable', + ...CHILD, delivery: 'steer', content: [{ type: 'text', text: '现在处理' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }, ]) - expect(api.callsOf('subagents.interruptByParent')).toEqual([ - { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, - ]) - expect(api.callsOf('session.history')).toEqual([]) - expect(api.callsOf('session.prompt')).toEqual([]) - expect(api.callsOf('session.cancel')).toEqual([]) + // Positional parameters cross the wire as positional args. + expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.requests('session/prompt')).toEqual([]) + expect(mock.log.requests('session/cancel')).toEqual([]) // A successful interrupt leaves no stop error behind. expect(session.getSnapshot().promptError).toBeNull() - expect(session.getSnapshot().subagent).toEqual({ - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + expect(session.getSnapshot().subagent).toEqual({ address: CHILD, parentAvailable: true }) }) - it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + it('forwards continuation image parts to the subagent prompt Remote unstripped', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() const content = [ { type: 'text' as const, text: '看这张图' }, @@ -462,26 +422,21 @@ describe('prompt and cancel errors', () => { const prompted = await session.prompt(content, 'queue') expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagents.prompt')).toEqual([ + expect(mock.log.requests('subagents/prompt')).toEqual([ { requestId: expect.any(String) as unknown as string, - parentSessionId: PARENT, childSessionId: SID, - mode: 'continuable', + ...CHILD, delivery: 'queue', content, - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }, ]) expect(session.getSnapshot().promptError).toBeNull() }) - it('lands an interrupt business failure in promptError with op=stop', async () => { - const api = new FakeApiClient() - api.onSubagentInterrupt = () => Promise.resolve(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + it('lands an interrupt business failure in promptError with op=stop', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) + mock.remote.subagents.interruptByParent.mockResolvedValue(err(new RemoteError('subagent/unauthorized', 'nope', { childSessionId: SID }))) await session.open() const cancelled = await session.cancel() expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent/unauthorized' } }) @@ -490,12 +445,8 @@ describe('prompt and cancel errors', () => { }) }) - it('rejects staged files instead of dropping them from subagent continuations', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - parentAvailable: true, - }) + it('rejects staged files instead of dropping them from subagent continuations', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD, parentAvailable: true }) await session.open() const prompted = await session.prompt([ @@ -510,17 +461,14 @@ describe('prompt and cancel errors', () => { details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, }, }) - expect(api.callsOf('subagents.prompt')).toEqual([]) + expect(mock.log.requests('subagents/prompt')).toEqual([]) }) - it('sends a one-shot address to the Host under the continuable marker', async () => { - const api = new FakeApiClient() - api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( + it('sends a one-shot address to the Host under the continuable marker', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: { ...CHILD, mode: 'one-shot' } }) + mock.remote.subagents.prompt.mockResolvedValue(err(new RemoteError( 'subagent/not-resumable', 'subagent cannot be resumed', { childSessionId: SID }, ))) - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, - }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') const cancelled = await session.cancel() @@ -528,30 +476,17 @@ describe('prompt and cancel errors', () => { // The Host reads the durable descriptor; the wire marker stays 'continuable'. expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent/not-resumable' } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagents.prompt')).toMatchObject([ - { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) + expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) + expect(mock.log.requests(FOLLOW)).toEqual([ + { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 50 }, ]) - expect(api.callsOf('subagents.interruptByParent')).toEqual([ - { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, - ]) - expect(api.callsOf('session.follow')).toEqual([ - { - address: { - kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', - }, - assistantStream: true, - maxMessages: 50, - }, - ]) - expect(api.callsOf('subagent.history')).toEqual([]) - expect(api.callsOf('session.cancel')).toEqual([]) + expect(mock.log.requests(PAGE)).toEqual([]) + expect(mock.log.requests('session/cancel')).toEqual([]) }) - it('delivers an image continuation to the Host without narrowing its upload parts', async () => { - const api = new FakeApiClient() - const session = new Session(SID, fakeRemote(api), { - address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, - }) + it('delivers an image continuation to the Host without narrowing its upload parts', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID, { address: CHILD }) await session.open() const prompted = await session.prompt( [{ type: 'text', text: '看图' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }], @@ -559,13 +494,13 @@ describe('prompt and cancel errors', () => { ) expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('subagents.prompt')).toMatchObject([ + expect(mock.log.requests('subagents/prompt')).toMatchObject([ { content: [{ type: 'text' }, { type: 'image', mediaType: 'image/png', data: 'AA==' }] }, ]) }) - it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => { - const { api, session } = makeSession() + it('publishes the first-prompt lifecycle synchronously before the Remote settles', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleBlank(true) expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, awaitingFirstTurn: false, @@ -579,20 +514,20 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: true, }) - expect(api.callsOf('session.prompt')).toMatchObject([{ + expect(mock.log.requests('session/prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }], - clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + clientTimeZone: TIME_ZONE, }]) session.handleRunning(true) expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) }) - it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => { - const { api, session } = makeSession() + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) session.handleBlank(true) - api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'session/agent-busy' } }) @@ -601,15 +536,18 @@ describe('prompt and cancel errors', () => { }) }) - it('propagates a non-Remote throw raised while cancelling', async () => { - const { api, session } = makeSession() - api.onCancel = () => Promise.reject(new Error('cancel transport down')) - await expect(session.cancel()).rejects.toThrow('cancel transport down') - expect(session.getSnapshot().promptError).toBeNull() + it('receives a carrier throw while cancelling as the client\'s gateway/internal fold, landing op=stop', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.cancel.mockImplementation(() => Promise.reject(new Error('cancel transport down'))) + const cancelled = await session.cancel() + expect(cancelled).toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/cancel failed: cancel transport down' }, + }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'gateway/internal' } }) }) - it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { - const { api, session } = makeSession() + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const result = await session.readAttachment('attachment-1' as never) expect(result).toEqual({ ok: true, @@ -618,19 +556,19 @@ describe('prompt and cancel errors', () => { data: Uint8Array.of(0), }, }) - expect(api.callsOf('session.attachment')).toEqual([{ + expect(mock.log.requests('session/attachment')).toEqual([{ sessionId: SID, attachmentId: 'attachment-1', }]) }) }) describe('rename', () => { - it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => { - const { api, session } = makeSession() - api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 })) + it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.rename.mockResolvedValue(ok({ title: '正名', seq: 7 })) const result = await session.rename(' 正名 ') expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } }) - expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) + expect(mock.log.requests('session/rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') // A stale lower-seq apply (the push-frame path routes into this same // store) must not roll the settled value back. @@ -638,69 +576,79 @@ describe('rename', () => { expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') }) - it('returns the business error untouched and folds a transport throw to internal', async () => { - const { api, session } = makeSession() - api.onRename = () => Promise.resolve(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID }))) + it('returns the business error untouched and a carrier throw as the client\'s gateway/internal fold', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'empty', { sessionId: SID }))) const rejected = await session.rename(' ') expect(rejected).toMatchObject({ ok: false, error: { code: 'session/title-invalid' } }) expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() - api.onRename = () => Promise.reject(new Error('rename transport down')) - await expect(session.rename('x')).rejects.toThrow('rename transport down') + mock.remote.session.rename.mockImplementation(() => Promise.reject(new Error('rename transport down'))) + const folded = await session.rename('x') + expect(folded).toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: rename transport down' } }) + expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() }) }) describe('remaining branches', () => { - it('propagates a non-Remote throw raised while prompting', async () => { - const { api, session } = makeSession() - api.onPrompt = () => Promise.reject(new Error('prompt wire down')) - await expect(session.prompt([{ type: 'text', text: 'x' }], 'queue')).rejects.toThrow('prompt wire down') - expect(session.getSnapshot().promptError).toBeNull() + it('receives a carrier throw while prompting as the client\'s gateway/internal fold, landing op=send', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.prompt.mockImplementation(() => Promise.reject(new Error('prompt wire down'))) + const prompted = await session.prompt([{ type: 'text', text: 'x' }], 'queue') + expect(prompted).toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/prompt failed: prompt wire down' }, + }) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'gateway/internal' } }) }) - it('cancel business error also lands op=stop promptError', async () => { - const { api, session } = makeSession() - api.onCancel = () => Promise.resolve(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' }))) + it('cancel business error also lands op=stop promptError', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.remote.session.cancel.mockResolvedValue(err(new RemoteError('session/agent-busy', 'nope', { reason: 'r' }))) await session.cancel() expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'session/agent-busy' } }) }) - it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { - const { api, session } = makeSession() - await session.loadOlder() // cold: no-op, zero calls - expect(api.calls).toEqual([]) - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) + it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.loadOlder() // cold: no-op, zero traffic + expect(mock.log.requests()).toEqual([]) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'x', 'y'), true))) await session.open() // err result: window unchanged - api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) + mock.remote.session.page.mockResolvedValue(err(new RemoteError('gateway/internal', 'x', {}))) await session.loadOlder() expect(eventSeqs(session)).toHaveLength(6) expect(session.getSnapshot().hasMore).toBe(true) // empty page: hasMore adopts the response - api.onHistory = () => histResponse([], false) + mock.remote.session.page.mockImplementation(pageRule(history([]))) await session.loadOlder() expect(session.getSnapshot().hasMore).toBe(false) // hasMore false now: further loadOlder is a guard no-op - const calls = api.calls.length + const sent = mock.log.requests().length await session.loadOlder() - expect(api.calls.length).toBe(calls) - // throw path: fail-soft with console.error + expect(mock.log.requests()).toHaveLength(sent) + // failure paths, after a resync reopens hasMore from the follow snapshot: a carrier throw arrives folded as a + // Remote failure and stays quiet; a local throw fails soft with console.error const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const prepend = vi.spyOn(SessionEventStream.prototype, 'prepend') try { await session.resync() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'x', 'y'), true) - await session.resync() - api.onHistory = () => Promise.reject(new Error('page wire down')) + mock.remote.session.page.mockImplementation(() => Promise.reject(new Error('page wire down'))) await session.loadOlder() - expect(errorSpy).toHaveBeenCalled() + expect(errorSpy).not.toHaveBeenCalled() + expect(session.getSnapshot().loadingOlder).toBe(false) + prepend.mockRejectedValueOnce(new Error('local fault')) + await session.loadOlder() + expect(errorSpy).toHaveBeenCalledWith('[session-controller] loadOlder failed:', expect.any(Error)) expect(session.getSnapshot().loadingOlder).toBe(false) } finally { + prepend.mockRestore() errorSpy.mockRestore() } }) - it('subscribe delivers snapshot-change notifications and unsubscribes', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('subscribe delivers snapshot-change notifications and unsubscribes', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) let notified = 0 const unsubscribe = session.subscribe(() => { notified++ }) await session.open() @@ -713,16 +661,11 @@ describe('remaining branches', () => { expect(notified).toBe(seen) }) - it('rejects an opening page that does not end at the opening cursor', async () => { - const { api, session } = makeSession() - let call = 0 - api.onHistory = () => { - call++ - return histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) - } - api.followCursor = 11 + it('rejects an opening page that does not end at the opening cursor', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')), { cursor: 11 })) await session.open() - expect(call).toBe(1) + expect(mock.log.requests(FOLLOW)).toHaveLength(1) const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('error') expect(snapshot.openError).toMatchObject({ @@ -731,8 +674,8 @@ describe('remaining branches', () => { expect(eventSeqs(session)).toEqual([]) }) - it('deduplicates repeated running flips and records removal', () => { - const { session } = makeSession() + it('deduplicates repeated running flips and records removal', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const before = session.getSnapshot() session.handleRunning(false) // already false: dedup branch expect(session.getSnapshot()).toBe(before) @@ -740,23 +683,23 @@ describe('remaining branches', () => { expect(session.getSnapshot().removed).toBe(true) }) - it('drops live events while cold/error (no window upkeep)', async () => { - const { api, session } = makeSession() - await follow(api, ev.user(SessionSeq(0), '冷态帧')) + it('drops live events while cold/error (no window upkeep)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await pushEvent(mock, ev.user(SessionSeq(0), '冷态帧')) // no follow is open: nothing receives it expect(eventSeqs(session)).toEqual([]) - api.onHistory = () => Promise.resolve(err(new RemoteError('gateway/internal', 'x', {}))) + mock.stream(FOLLOW, followScript(err(new RemoteError('gateway/internal', 'x', {})))) await session.open() - await follow(api, ev.user(SessionSeq(0), '错态帧')) + await pushEvent(mock, ev.user(SessionSeq(0), '错态帧')) expect(eventSeqs(session)).toEqual([]) }) - it('preserves a Host-reported failure that terminates the live source', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('preserves a Host-reported failure that terminates the live source', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() const failure = new RemoteError('session/not-found', 'session disappeared', { sessionId: SID }) - api.failStreams(failure) + mock.streams.fail(FOLLOW, failure) await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) expect(session.getSnapshot().openError).toMatchObject({ @@ -764,151 +707,120 @@ describe('remaining branches', () => { }) }) - it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('coalesces queued gap frames behind one repair and exposes a failed repair', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const gate = deferred>>() - let repairs = 0 - api.onHistory = () => { - repairs++ - return gate.promise - } - const deliveries = Promise.all([ - follow(api, ev.user(SessionSeq(9), '洞一')), - follow(api, ev.user(SessionSeq(10), '洞二')), - ]) - await vi.waitFor(() => { expect(repairs).toBe(1) }) - gate.reject(new RemoteError('gateway/internal', 'repair wire down', {})) - await deliveries + const gate = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(gate.promise)) + await pushEvent(mock, ev.user(SessionSeq(9), '洞一')) + await pushEvent(mock, ev.user(SessionSeq(10), '洞二')) + await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) }) + gate.resolve(err(new RemoteError('gateway/internal', 'repair wire down', {}))) await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'repair wire down' }) expect(eventSeqs(session)).toHaveLength(6) }) - it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise + it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const stale = Promise.withResolvers>() + mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) const resynced = session.resync() stale.reject(new Error('stale wire')) await Promise.all([opening, resynced]) expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error }) - it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise + it('drops a stale doOpen whose history resolved successfully after resync superseded it', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const stale = Promise.withResolvers>() + mock.stream(FOLLOW, followScript(() => stale.promise)) const opening = session.open() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代')) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) const resynced = session.resync() - stale.resolve(ok({ - records: entries(plainTurn(SessionSeq(0), 0, '旧', '代')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) // success, but its generation is gone + stale.resolve(history(plainTurn(SessionSeq(0), 0, '旧', '代'))) // success, but its generation is gone await Promise.all([opening, resynced]) expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq)) }) - it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('drops a gap repair superseded by a full resync while its pull was in flight', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() - const repairPull = deferred>>() - api.onHistory = () => repairPull.promise - const delivery = follow(api, ev.user(SessionSeq(9), '洞')) - await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) }) - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, 'c', 'd')) + const repairPull = Promise.withResolvers>() + mock.remote.session.page.mockImplementation(pageRule(repairPull.promise)) + await pushEvent(mock, ev.user(SessionSeq(9), '洞')) + await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) }) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'c', 'd')))) const resynced = session.resync() // bumps the generation - repairPull.resolve(ok({ - records: entries(plainTurn(SessionSeq(0), 0, '旧', '页')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'stale' }, - })) // repair result: stale, dropped - await Promise.all([delivery, resynced]) + repairPull.resolve(history(plainTurn(SessionSeq(0), 0, '旧', '页'))) // repair result: stale, dropped + await resynced expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, 'c', 'd').map(event => event.seq)) }) - it('successful cancel leaves no promptError', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('successful cancel leaves no promptError', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() const result = await session.cancel() expect(result.ok).toBe(true) expect(session.getSnapshot().promptError).toBeNull() }) - it('dispose is a reserved no-op on resident instances', async () => { - const { session } = makeSession() + it('dispose is a reserved no-op on resident instances', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) await expect(session.dispose()).resolves.toBeUndefined() }) - it('carries raw history and follow events through the event feed', async () => { - const { api, session } = makeSession() + it('carries raw history and follow events through the event feed', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) const historyCall = ev.toolCall(SessionSeq(6), 1, 'h1', 'bash', '{"cmd":"pwd"}') const historyResult = ev.toolResult(SessionSeq(7), 1, 'h1', 'done') - api.onHistory = () => Promise.resolve(ok({ - records: [ - ...entries(plainTurn(SessionSeq(0), 0, 'a', 'b')), - { type: 'event', event: historyCall }, - { type: 'event', event: historyResult }, - ] as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) + mock.stream(FOLLOW, followScript(history([...plainTurn(SessionSeq(0), 0, 'a', 'b'), historyCall, historyResult]))) await session.open() - expect(windowEntries(session).slice(-2)).toEqual([ - { type: 'event', event: historyCall }, - { type: 'event', event: historyResult }, - ]) + expect(windowEntries(session).slice(-2)).toEqual(entries([historyCall, historyResult])) const liveCall = ev.toolCall(SessionSeq(8), 2, 'l1', 'write', '{"file_path":"a.ts"}') - await follow(api, liveCall) - expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall }) + await pushEvent(mock, liveCall) + expect(windowEntries(session).at(-1)).toEqual(frame(liveCall)) const liveResult = ev.toolResult(SessionSeq(9), 2, 'l1', 'ok') - await follow(api, liveResult) - expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult }) + await pushEvent(mock, liveResult) + expect(windowEntries(session).at(-1)).toEqual(frame(liveResult)) }) }) describe('resync', () => { - it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '旧', '窗')) + it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '旧', '窗')))) await session.open() const oldWindow = session.eventSource.getSnapshot() - const replacement = deferred>>() - api.followCursor = 15 - api.onHistory = () => replacement.promise + const replacement = Promise.withResolvers() + // The reconnect generation: its snapshot is gated, then two live frames land out of order right behind it. + mock.stream(FOLLOW, async ([request], stream) => { + stream.push(followSnapshot(await replacement.promise, request as SessionFollowRequest)) + stream.push(frame(ev.user(SessionSeq(17), '后到高位'))) + stream.push(frame(ev.user(SessionSeq(16), '后到低位'))) + }) + mock.remote.session.page.mockImplementation(pageRule(history([ + ...plainTurn(SessionSeq(10), 2, '终', '页'), + ev.user(SessionSeq(16), '后到低位'), + ev.user(SessionSeq(17), '后到高位'), + ]))) const publications: ReturnType[] = [] const off = session.eventSource.subscribe(() => { publications.push(session.eventSource.getSnapshot()) }) const syncing = session.resync() - await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) }) + await vi.waitFor(() => { expect(mock.log.requests(FOLLOW)).toHaveLength(2) }) expect(session.eventSource.getSnapshot()).toBe(oldWindow) expect(publications).toEqual([]) - api.onHistory = () => histResponse([ - ...plainTurn(SessionSeq(10), 2, '终', '页'), - ev.user(SessionSeq(16), '后到低位'), - ev.user(SessionSeq(17), '后到高位'), - ]) - const liveDeliveries = Promise.all([ - follow(api, ev.user(SessionSeq(17), '后到高位')), - follow(api, ev.user(SessionSeq(16), '后到低位')), - ]) - expect(session.eventSource.getSnapshot()).toBe(oldWindow) - replacement.resolve(ok({ - records: entries(plainTurn(SessionSeq(10), 2, '终', '页')) as never[], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - })) - await Promise.all([syncing, liveDeliveries]) + replacement.resolve(historyValue(plainTurn(SessionSeq(10), 2, '终', '页'))) + await syncing await vi.waitFor(() => { expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) }) @@ -920,13 +832,13 @@ describe('resync', () => { off() }) - it('rebuilds the window without clearing control state; cold instances no-op', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, 'a', 'b')) + it('rebuilds the window without clearing control state', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')))) await session.open() session.handleRunning(true) session.handleAgentError('still visible') - api.onHistory = () => histResponse([...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')]) + mock.stream(FOLLOW, followScript(history([...plainTurn(SessionSeq(0), 0, 'a', 'b'), ...plainTurn(SessionSeq(6), 1, 'c', 'd')]))) await session.resync() const snapshot = session.getSnapshot() expect(snapshot.openState).toBe('open') @@ -934,17 +846,20 @@ describe('resync', () => { expect(snapshot.lastAgentError).toBe('still visible') expect(eventSeqs(session)).toHaveLength(12) - const cold = makeSession() - await cold.session.resync() - expect(cold.api.calls).toEqual([]) // never opened: no traffic }) - it('drops a stale in-flight open superseded by resync (generation guard)', async () => { - const { api, session } = makeSession() - const stale = deferred>>() - api.onHistory = () => stale.promise + it('does not resync a cold Session', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.resync() + expect(mock.log.requests()).toEqual([]) + }) + + it('drops a stale in-flight open superseded by resync (generation guard)', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + const stale = Promise.withResolvers>() + mock.stream(FOLLOW, followScript(() => stale.promise)) const firstOpen = session.open() - api.onHistory = () => histResponse(plainTurn(SessionSeq(6), 1, '新', '代')) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, '新', '代')))) const resynced = session.resync() stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late await firstOpen @@ -953,18 +868,17 @@ describe('resync', () => { expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error expect(eventSeqs(session)).toEqual(plainTurn(SessionSeq(6), 1, '新', '代').map(event => event.seq)) }) - }) describe('snapshot ownership', () => { - it('publishes event-window appends without changing an unrelated Session snapshot', async () => { - const { api, session } = makeSession() - api.onHistory = () => histResponse(plainTurn(SessionSeq(0), 0, '稳', '定')) + it('publishes event-window appends without changing an unrelated Session snapshot', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, '稳', '定')))) await session.open() const sessionBefore = session.getSnapshot() const windowBefore = session.eventSource.getSnapshot() const firstEntry = windowBefore.entries[0] - await follow(api, ev.user(SessionSeq(6), '追加')) + await pushEvent(mock, ev.user(SessionSeq(6), '追加')) const windowAfter = session.eventSource.getSnapshot() expect(session.getSnapshot()).toBe(sessionBefore) expect(windowAfter).not.toBe(windowBefore) diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index d017472aa6..ca34d6cb5a 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 5bf6be8d5a..49b780da65 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -83,7 +83,9 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts new file mode 100644 index 0000000000..f18cec224d --- /dev/null +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -0,0 +1,100 @@ +/** + * The Remote side of one Workspace registry under test: default answers for + * every `workspace/*` command a `ClientWorkspaceModel` calls, builders for the + * rows and frames of the `workspace/follow` stream, and a script that hands + * each physical generation of that stream to its own script. The stream's + * boot-time opening baseline lives in `remoteDefaultResponses`. + */ +import { ok, type RemoteTable, type StreamScript } from '@deepseek-ai/dsh-remote-mock' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceId, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, + WorkspaceView, +} from '../../src/types.ts' + +/** The Workspace state stream endpoint. */ +export const FOLLOW = 'workspace/follow' + +/** The opening frame of one follow generation. */ +export type WorkspaceBaselineFrame = Extract + +/** + * The failure branch of a Remote result. + * @param error - the owner-declared failure. + * @returns the result. + */ +export function err(error: RemoteFailure): RemoteResult { + return { ok: false, error } +} + +/** + * One Workspace row whose id doubles as its title and path segment. + * @param id - Workspace id. + * @param overrides - fields replacing the derived ones. + * @returns the row. + */ +export function workspace(id: string, overrides: Partial = {}): WorkspaceView { + return { + workspaceId: id as WorkspaceId, + path: `/work/${id}`, + title: id, + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +/** + * A baseline frame holding the named Workspaces and no archived Sessions. + * @param ids - Workspace ids in registry order. + * @returns the frame. + */ +export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame { + return { type: 'baseline', value: { items: ids.map(id => workspace(id)), archivedSessionIds: [] } } +} + +/** + * `workspace/follow` script running the n-th open on the n-th script; an open + * past the last script fails the stream. + * @param generations - one script per physical generation, in open order. + * @returns the script. + */ +export function followGenerations(generations: readonly StreamScript[]): StreamScript { + let opened = 0 + return (args, stream) => { + const generation = generations[opened++] + if (generation === undefined) throw new Error('no scripted Workspace follow generation') + return generation(args, stream) + } +} + +/** Default answers: every command accepted and echoed back as the row or set it names. */ +export const workspaceWorld: RemoteTable = { + unary: { + 'workspace/create': (request: WorkspaceCreateRequest): RemoteResult => ok({ + workspace: workspace('created', { path: request.path }), created: true, + }), + 'workspace/rename': (request: WorkspaceRenameRequest): RemoteResult => ok({ + workspace: workspace(String(request.workspaceId), { title: request.title }), + }), + 'workspace/delete': (_request: WorkspaceDeleteRequest): RemoteResult => ok({ deleted: true }), + 'workspace/insertBefore': (request: WorkspaceInsertBeforeRequest): RemoteResult => ok({ workspaceIds: [request.workspaceId] }), + 'workspace/insertSessionBefore': (request: WorkspaceInsertSessionBeforeRequest): RemoteResult => ok({ + workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }), + }), + 'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult => ok({ archivedSessionIds: [request.sessionId] }), + }, +} diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b050b7c22c..3ee7462d03 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -1,101 +1,53 @@ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import { - RemoteStream, - RemoteStreamCarrierError, - type ClientRemote, - type RemoteStreamOptions, -} from '@deepseek-ai/dsh-api-gateway/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +/** + * Workspace Controller client plugin, state stream, and command facade driven + * through the assembled Gateway client: every `workspace/*` call crosses the + * roster's own Connection and is answered by endpoint name. + */ + +import { describe, expect, onTestFinished, vi } from 'vitest' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import { SessionId } from '@deepseek-ai/dsh-session/types' -import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' -import * as WorkspaceClientPlugin from '../src/client/index.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { frames, openStream, type RemoteMock, type StreamScript } from '@deepseek-ai/dsh-remote-mock' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ClientWorkspaceModel, createWorkspaceStateStream, WorkspaceController, WorkspaceCreateError, type WorkspaceFollowSink, - type WorkspaceRemote, } from '../src/client/index.ts' -import type { - WorkspaceArchiveSessionRequest, - WorkspaceArchiveValue, - WorkspaceCreateRequest, - WorkspaceCreateValue, - WorkspaceDeleteRequest, - WorkspaceDeleteValue, - WorkspaceFollowFrame, - WorkspaceInsertBeforeRequest, - WorkspaceInsertSessionBeforeRequest, - WorkspaceOrderValue, - WorkspaceRenameRequest, - WorkspaceId, - WorkspaceValue, - WorkspaceView, -} from '../src/types.ts' +import type { WorkspaceFollowFrame, WorkspaceId } from '../src/types.ts' +import { FOLLOW, baseline, err, followGenerations, workspace, workspaceWorld } from './remote/workspace.client.ts' -const AVAILABLE_CONNECTION = { - generation: { - getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), - subscribe: () => () => {}, - }, -} - -function workspaceClient( - remote: WorkspaceRemote, - connection: Pick = AVAILABLE_CONNECTION, -): ClientRemote { - return { - workspace: remote, - $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), - } as unknown as ClientRemote -} - -interface Generation { - readonly frames: readonly WorkspaceFollowFrame[] - readonly error?: unknown - readonly hold?: boolean - readonly afterAbort?: () => void - readonly afterAbortError?: unknown -} - -const baseline = (id?: string): Extract => ({ - type: 'baseline', - value: { - items: id === undefined ? [] : [{ - workspaceId: id as never, - path: `/work/${id}`, - title: id, - sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }], - archivedSessionIds: [], - }, -}) +const SELF = '@deepseek-ai/dsh-api-workspace-controller' +/** The plugin as the web bundle composes it: itself plus the Gateway client, the Connection, and the Typert registry. */ +const PLUGIN_ROSTER = webApp.closure([SELF]) +/** A stream or model built by hand talks through the Gateway client alone. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const pluginTest = createClientTest({ roster: PLUGIN_ROSTER }) +const it = createClientTest({ roster: API_ROSTER }) +/** The first client boot pays the cold module transform of the plugin cone. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 const wid = (id: string): WorkspaceId => id as WorkspaceId const sid = (id: string): SessionId => SessionId(id) -function workspace(id: string, overrides: Partial = {}): WorkspaceView { - return { - workspaceId: wid(id), - path: `/work/${id}`, - title: id, - sessionIds: [], - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - ...overrides, - } +/** Boot the plugin with `follow` answering its state stream. */ +async function pluginClient(mock: RemoteMock, start: () => Promise, follow: StreamScript): Promise { + mock.stream(FOLLOW, follow) + return start() } -function remoteOk(value: T): RemoteResult { - return { ok: true, value } +/** Boot the Gateway client with the Workspace command answers registered, so `remote.workspace` is provided. */ +async function gatewayClient(mock: RemoteMock, start: () => Promise): Promise<{ remote: ClientRemote; client: TestClient }> { + mock.load(workspaceWorld) + const client = await start() + return { remote: client.ctx.remote, client } } -function remoteFailure(error: RemoteFailure): RemoteResult { - return { ok: false, error } +const carrierLoss = (message: string): StreamScript => (_args, stream) => { + stream.fail(new RemoteStreamCarrierError(message)) } function accepts(overrides: Partial = {}): WorkspaceFollowSink { @@ -110,166 +62,75 @@ function accepts(overrides: Partial = {}): WorkspaceFollowS } } -class ScriptedWorkspaceRemote implements WorkspaceRemote { - readonly signals: AbortSignal[] = [] - calls = 0 - - constructor(private readonly generations: readonly Generation[]) {} - - create(_request: WorkspaceCreateRequest): Promise> { - throw new Error('unused') - } - - rename(_request: WorkspaceRenameRequest): Promise> { - throw new Error('unused') - } - - delete(_request: WorkspaceDeleteRequest): Promise> { - throw new Error('unused') - } - - insertBefore(_request: WorkspaceInsertBeforeRequest): Promise> { - throw new Error('unused') - } - - insertSessionBefore(_request: WorkspaceInsertSessionBeforeRequest): Promise> { - throw new Error('unused') - } - - archiveSession(_request: WorkspaceArchiveSessionRequest): Promise> { - throw new Error('unused') - } - - async *follow(signal = new AbortController().signal): AsyncIterable { - const generation = this.generations[this.calls++] - if (generation === undefined) throw new Error('no scripted Workspace generation') - this.signals.push(signal) - for (const frame of generation.frames) yield frame - if (generation.error !== undefined) throw generation.error - if (generation.hold === true && !signal.aborted) { - await new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - generation.afterAbort?.() - if (generation.afterAbortError !== undefined) throw generation.afterAbortError - } - } -} - -class CommandWorkspaceRemote implements WorkspaceRemote { - readonly create = vi.fn(request => Promise.resolve(remoteOk({ - workspace: workspace('created', { path: request.path }), - created: true, - }))) - - readonly rename = vi.fn(request => Promise.resolve(remoteOk({ - workspace: workspace(String(request.workspaceId), { title: request.title }), - }))) - - readonly delete = vi.fn(() => Promise.resolve(remoteOk({ deleted: true }))) - - readonly insertBefore = vi.fn(request => Promise.resolve(remoteOk({ - workspaceIds: [request.workspaceId], - }))) - - readonly insertSessionBefore = vi.fn(request => Promise.resolve(remoteOk({ - workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }), - }))) - - readonly archiveSession = vi.fn(request => Promise.resolve(remoteOk({ - archivedSessionIds: [request.sessionId], - }))) - - async *follow(_signal?: AbortSignal): AsyncIterable {} -} - -async function waitFor(check: () => void): Promise { - for (let attempt = 0; attempt < 40; attempt++) { - try { - check() - return - } catch { - await Promise.resolve() - } - } - check() -} - -function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { - const connection: ConnectionHandle = { - isLoopback: true, - generation: AVAILABLE_CONNECTION.generation, - state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, - rpc: { - call: () => Promise.reject(new Error('unexpected generic RPC call')), - }, - reconnect: () => {}, - registerGenerationSource: () => () => {}, - start: () => ({ stop: () => {} }), - } - ctx.reflect.provide('connection', connection) - ctx.reflect.provide('remote', workspaceClient(remote, connection)) - ctx.reflect.provide('remote.workspace', remote) +function streamStates(mock: RemoteMock): string[] { + return mock.log.streams(FOLLOW).map(row => row.state) } describe('Workspace Controller Client apply', () => { - it('provides the Workspace service and stops its follow generation with the plugin fiber', async () => { - const ctx = new Context() - const remote = new ScriptedWorkspaceRemote([{ frames: [baseline('mounted')], hold: true }]) - provideClientServices(ctx, remote) - const fiber = ctx.plugin(WorkspaceClientPlugin) - await fiber - await waitFor(() => { - expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + pluginTest('provides the Workspace service and stops its follow generation with the plugin fiber', async ({ mock, start }) => { + const client = await pluginClient(mock, start, openStream([baseline('mounted')])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', items: [{ workspaceId: 'mounted' }], }) }) - await fiber.dispose() + await client.unload(SELF) - expect(remote.signals[0]?.aborted).toBe(true) - expect(ctx.get('workspaces')).toBeUndefined() + expect(streamStates(client.mock)).toEqual(['cancelled']) + expect(client.ctx.get('workspaces')).toBeUndefined() + }, COLD_BOOT_TIMEOUT_MS) + + pluginTest('reopens the follow and re-provides the service across a Loader rebuild', async ({ mock, start }) => { + const client = await pluginClient(mock, start, openStream([baseline('mounted')])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', items: [{ workspaceId: 'mounted' }] }) + }) + const before = client.ctx.workspaces + + await client.reload(SELF) + + expect(streamStates(client.mock)).toEqual(['cancelled', 'open']) + expect(client.ctx.workspaces).not.toBe(before) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', items: [{ workspaceId: 'mounted' }] }) + }) }) - it('publishes exhausted carrier retries as a gateway/internal error state', async () => { - const ctx = new Context() + pluginTest('publishes exhausted carrier retries as a gateway/internal error state', async ({ mock, start }) => { // Neither generation reaches an accepted baseline, so the retry budget runs // out and the escaping carrier failure crosses the stream boundary marked. - const remote = new ScriptedWorkspaceRemote([ - { frames: [], error: new RemoteStreamCarrierError('generation lost') }, - { frames: [], error: new RemoteStreamCarrierError('generation lost again') }, - ]) - provideClientServices(ctx, remote) - const fiber = ctx.plugin(WorkspaceClientPlugin) - await fiber - await waitFor(() => { - expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + const client = await pluginClient(mock, start, followGenerations([ + carrierLoss('generation lost'), + carrierLoss('generation lost again'), + ])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ state: 'error', error: { code: 'gateway/internal', message: 'generation lost again' }, }) }) - expect(remote.calls).toBe(2) - await fiber.dispose() + expect(streamStates(client.mock)).toEqual(['failed', 'failed']) }) - it('marks carrier loss while retrying and publishes a later protocol failure', async () => { - const ctx = new Context() - const remote = new ScriptedWorkspaceRemote([ - { - frames: [baseline('old')], - error: new RemoteStreamCarrierError('generation lost'), - }, - { frames: [baseline('fresh'), baseline('duplicate')] }, - ]) - provideClientServices(ctx, remote) + pluginTest('marks carrier loss while retrying and publishes a later protocol failure', async ({ mock, start }) => { const carrierFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleCarrierFailure') const streamFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleStreamFailure') - const fiber = ctx.plugin(WorkspaceClientPlugin) - await fiber - await waitFor(() => { - expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + onTestFinished(() => { + carrierFailure.mockRestore() + streamFailure.mockRestore() + }) + const client = await pluginClient(mock, start, followGenerations([ + (_args, stream) => { + stream.push(baseline('old')) + stream.fail(new RemoteStreamCarrierError('generation lost')) + }, + openStream([baseline('fresh'), baseline('duplicate')]), + ])) + await vi.waitFor(() => { + expect(client.ctx.workspaces.list.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', items: [{ workspaceId: 'fresh' }], @@ -279,38 +140,28 @@ describe('Workspace Controller Client apply', () => { expect(carrierFailure).toHaveBeenCalledOnce() expect(streamFailure).toHaveBeenCalledOnce() - await fiber.dispose() }) }) describe('Workspace state stream', () => { - it('delivers one baseline followed by increments', async () => { + it('delivers one baseline followed by increments', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) const opening = baseline('one') - const workspace = opening.value.items[0]! - const remote = new ScriptedWorkspaceRemote([{ - frames: [ - opening, - { type: 'upsert', workspace }, - { type: 'remove', workspaceId: workspace.workspaceId }, - { type: 'order', workspaceIds: [workspace.workspaceId] }, - { type: 'archived', archivedSessionIds: ['session-one' as never] }, - ], - hold: true, - }]) + const view = opening.value.items[0]! + const increments: WorkspaceFollowFrame[] = [ + { type: 'upsert', workspace: view }, + { type: 'remove', workspaceId: view.workspaceId }, + { type: 'order', workspaceIds: [view.workspaceId] }, + { type: 'archived', archivedSessionIds: [sid('session-one')] }, + ] + mock.stream(FOLLOW, openStream([opening, ...increments])) const replaceBaseline = vi.fn() const upsertView = vi.fn() const removeView = vi.fn() const replaceOrder = vi.fn() const replaceArchived = vi.fn() - const accept = accepts({ - replaceBaseline, - upsertView, - removeView, - replaceOrder, - replaceArchived, - }) - const stream = createWorkspaceStateStream(workspaceClient(remote), { - accept, + const stream = createWorkspaceStateStream(remote, { + accept: accepts({ replaceBaseline, upsertView, removeView, replaceOrder, replaceArchived }), failed: vi.fn(), }) @@ -319,24 +170,28 @@ describe('Workspace state stream', () => { await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() }) expect(replaceBaseline).toHaveBeenCalledWith(opening.value) - expect(upsertView).toHaveBeenCalledWith(workspace) - expect(removeView).toHaveBeenCalledWith(workspace.workspaceId) - expect(replaceOrder).toHaveBeenCalledWith([workspace.workspaceId]) + expect(upsertView).toHaveBeenCalledWith(view) + expect(removeView).toHaveBeenCalledWith(view.workspaceId) + expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId]) expect(replaceArchived).toHaveBeenCalledWith(['session-one']) await stream.dispose() - expect(remote.signals[0]?.aborted).toBe(true) + expect(streamStates(mock)).toEqual(['cancelled']) }) - it('retains the old state across carrier loss and applies the replacement baseline', async () => { + it('retains the old state across carrier loss and applies the replacement baseline', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) const carrier = new RemoteStreamCarrierError('socket lost') - const remote = new ScriptedWorkspaceRemote([ - { frames: [baseline('old')], error: carrier }, - { frames: [baseline('fresh')], hold: true }, - ]) + mock.stream(FOLLOW, followGenerations([ + (_args, stream) => { + stream.push(baseline('old')) + stream.fail(carrier) + }, + openStream([baseline('fresh')]), + ])) const replaceBaseline = vi.fn() const carrierFailed = vi.fn() const failed = vi.fn() - const stream = createWorkspaceStateStream(workspaceClient(remote), { + const stream = createWorkspaceStateStream(remote, { accept: accepts({ replaceBaseline }), carrierFailed, failed, @@ -351,14 +206,15 @@ describe('Workspace state stream', () => { await stream.dispose() }) - it('classifies a normal end after the opening baseline as carrier loss', async () => { - const remote = new ScriptedWorkspaceRemote([ - { frames: [baseline('old')] }, - { frames: [baseline('fresh')], hold: true }, - ]) + it('classifies a normal end after the opening baseline as carrier loss', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, followGenerations([ + frames([baseline('old')]), + openStream([baseline('fresh')]), + ])) const replaceBaseline = vi.fn() const carrierFailed = vi.fn() - const stream = createWorkspaceStateStream(workspaceClient(remote), { + const stream = createWorkspaceStateStream(remote, { accept: accepts({ replaceBaseline }), carrierFailed, failed: vi.fn(), @@ -372,21 +228,20 @@ describe('Workspace state stream', () => { await stream.dispose() }) - it('suppresses callback failure after disposal begins', async () => { + it('suppresses callback failure after disposal begins', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, frames([baseline()])) const failed = vi.fn() let closing: Promise | undefined - const stream = createWorkspaceStateStream( - workspaceClient(new ScriptedWorkspaceRemote([{ frames: [baseline()] }])), - { - accept: accepts({ - replaceBaseline: () => { - closing = stream.dispose() - throw new Error('disposed callback') - }, - }), - failed, - }, - ) + const stream = createWorkspaceStateStream(remote, { + accept: accepts({ + replaceBaseline: () => { + closing = stream.dispose() + throw new Error('disposed callback') + }, + }), + failed, + }) stream.start() await vi.waitFor(() => { expect(closing).toBeDefined() }) @@ -394,28 +249,27 @@ describe('Workspace state stream', () => { expect(failed).not.toHaveBeenCalled() }) - it.each([ + it.for([ { name: 'an increment before the baseline', - frames: [{ type: 'remove', workspaceId: 'one' as never }] as WorkspaceFollowFrame[], + items: [{ type: 'remove', workspaceId: wid('one') }] as WorkspaceFollowFrame[], message: 'update before its opening snapshot', }, { name: 'a duplicate baseline', - frames: [baseline(), baseline()] as WorkspaceFollowFrame[], + items: [baseline(), baseline()] as WorkspaceFollowFrame[], message: 'more than one opening snapshot', }, { name: 'a normal end before the baseline', - frames: [] as WorkspaceFollowFrame[], + items: [] as WorkspaceFollowFrame[], message: 'ended before its opening snapshot', }, - ])('reports $name as a terminal failure', async ({ frames, message }) => { + ])('reports $name as a terminal failure', async ({ items, message }, { mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, frames(items)) const failed = vi.fn() - const stream = createWorkspaceStateStream( - workspaceClient(new ScriptedWorkspaceRemote([{ frames }])), - { accept: accepts(), failed }, - ) + const stream = createWorkspaceStateStream(remote, { accept: accepts(), failed }) stream.start() await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) @@ -423,17 +277,20 @@ describe('Workspace state stream', () => { expect(failure).toBeInstanceOf(Error) if (!(failure instanceof Error)) throw new Error('expected Workspace stream failure') expect(failure.message).toContain(message) + // A protocol failure is terminal: no retry opens a second generation. + expect(mock.log.requests(FOLLOW)).toHaveLength(1) await stream.dispose() }) - it('restarts a live generation without reporting cancellation as failure', async () => { - const remote = new ScriptedWorkspaceRemote([ - { frames: [baseline('first')], hold: true }, - { frames: [baseline('second')], hold: true }, - ]) + it('restarts a live generation without reporting cancellation as failure', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, followGenerations([ + openStream([baseline('first')]), + openStream([baseline('second')]), + ])) const replaceBaseline = vi.fn() const failed = vi.fn() - const stream = createWorkspaceStateStream(workspaceClient(remote), { + const stream = createWorkspaceStateStream(remote, { accept: accepts({ replaceBaseline }), failed, }) @@ -443,18 +300,20 @@ describe('Workspace state stream', () => { stream.restart() await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) expect(failed).not.toHaveBeenCalled() + expect(streamStates(mock)).toEqual(['cancelled', 'open']) await stream.dispose() }) }) describe('WorkspaceController', () => { - it('publishes the model source and exposes successful Workspace commands', async () => { - const remote = new CommandWorkspaceRemote() - const model = new ClientWorkspaceModel(remote) + it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const model = new ClientWorkspaceModel(remote.workspace) model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] }) - const controller = new WorkspaceController(new Context(), model) + const controller = new WorkspaceController(client.ctx, model) expect(controller.list).toBe(model) + expect(client.ctx.workspaces.list).toBe(model) await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() @@ -463,32 +322,52 @@ describe('WorkspaceController', () => { }) await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() + // Each command crosses the wire as one positional request object. + expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) + expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }]) + expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }]) + expect(mock.log.requests('workspace/insertSessionBefore')).toEqual([{ workspaceId: 'one', sessionId: 'session' }]) + expect(mock.log.requests('workspace/archiveSession')).toEqual([{ sessionId: 'session' }]) + expect(mock.log.requests('workspace/delete')).toEqual([{ workspaceId: 'one' }]) }) - it('maps generated business failures to the command facade errors', async () => { - const remote = new CommandWorkspaceRemote() - const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote)) + it('maps generated business failures to the command facade errors', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const controller = new WorkspaceController(client.ctx, new ClientWorkspaceModel(remote.workspace)) const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) - remote.create.mockResolvedValueOnce(remoteFailure(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) + mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) - await expect(create).rejects.toThrow('workspace/invalid-path: missing path') + await expect(create).rejects.toThrow('workspace create failed: workspace/invalid-path: missing path') - remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + mock.remote.workspace.rename.mockResolvedValueOnce(err(missingWorkspace)) await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace/not-found: gone') - remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + mock.remote.workspace.delete.mockResolvedValueOnce(err(missingWorkspace)) await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace/not-found: gone') - remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + mock.remote.workspace.insertBefore.mockResolvedValueOnce(err(missingWorkspace)) await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace/not-found: gone') - remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession)) + mock.remote.workspace.archiveSession.mockResolvedValueOnce(err(missingSession)) await expect(controller.archiveSession(sid('session'))) .rejects.toThrow('workspace session archive failed: session/not-found: missing session') - remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure(new RemoteError( + mock.remote.workspace.insertSessionBefore.mockResolvedValueOnce(err(new RemoteError( 'workspace/move-invalid', 'invalid move', { workspaceId: wid('missing'), sessionId: sid('session') }, ))) await expect(controller.insertSessionBefore(wid('missing'), sid('session'))) .rejects.toThrow('workspace move failed: workspace/move-invalid: invalid move') }) + + it('receives a carrier throw as the client\'s gateway/internal fold, never as a rejection', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const controller = new WorkspaceController(client.ctx, new ClientWorkspaceModel(remote.workspace)) + + mock.remote.workspace.create.mockImplementation(() => Promise.reject(new Error('create wire down'))) + const create = controller.create({ path: '/work/created' }) + await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) + await expect(create).rejects.toThrow( + 'workspace create failed: gateway/internal: client api: workspace/create failed: create wire down', + ) + expect(mock.log.calls('workspace/create').map(call => call.state)).toEqual(['failed']) + }) }) diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index b1170a9d7f..c5130dbe89 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index ebacfdd1df..bd5a9411bf 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: a14490f855268cdaa65bc0d3d8968ece56d25bf3 -README.zh.md: 44e8205dfdc7debd625c1ad4819f56bcfbe94728 +README.md: ea15a8700bf07fc640375b0d1ea4af18c7ff2199 +README.zh.md: f9b24dfd8e3b67a144bf724616d203cbf6e4dd63 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index a14490f855..ea15a8700b 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -85,7 +85,7 @@ Objects land at `/attachments/v1/objects//`; eq Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. -Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Request versions live below `/cache/attachments/request-images/`, resolved by `dshCachePath`; an explicit `dshHome` setting applies to both cache and durable storage. Clearing this cache between requests preserves durable attachments, and later reads regenerate the variants. `readImageRequest` scales without enlargement to the route-chosen target, resizing by the long edge only so the encoder derives the short edge as the route predicts, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, target dimensions, byte target, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 44e8205dfd..f9b24dfd8e 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" 准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此,即使宽高比极端,图片也会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 -请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +请求版本位于由 `dshCachePath` 解析的 `/cache/attachments/request-images/`;显式 `dshHome` 设置同时适用于缓存与持久存储。在两次请求之间清空此缓存会保留持久附件,后续读取会重新生成请求版本。`readImageRequest` 在不放大的前提下缩放到路由选定的目标尺寸,缩放只按长边给定,短边由编码器按路由预测的方式推出,随后通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、目标尺寸、字节目标与固定编码参数;缓存字节会先通过文件头探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 8b45903d74..1d2a37a9e4 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 7415bf940f..34959d088a 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -1,6 +1,6 @@ /** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' @@ -8,14 +8,14 @@ import type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' -import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { dshCachePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' import { CompressionLimiter, compressionFailure } from './compression-limiter.ts' import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' @@ -166,12 +166,15 @@ export class LocalAttachmentStore extends AttachmentStore { readonly normalizationPolicy: Readonly /** Resolved instance-level compression limit. */ readonly imageCompressionConcurrency: number + private readonly cacheRoot: string private readonly compression: CompressionLimiter private readonly requestInflight = new Map>() constructor(ctx: Context, config: Config) { super(ctx) - this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) + const dshHome = resolveDshHome(config.dshHome) + this.root = join(dshHome, 'attachments', 'v1') + this.cacheRoot = dshCachePath({ dshHome }, 'attachments') this.imageLimits = Object.freeze({ maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, @@ -244,20 +247,20 @@ export class LocalAttachmentStore extends AttachmentStore { override async readImageRequest( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ): Promise { - return this.requestVersion(ref, policy, undefined, signal) + return this.requestVersion(ref, target, undefined, signal) } private requestVersion( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, stored: StoredImageAttachment | undefined, signal: AbortSignal | undefined, ): Promise { signal?.throwIfAborted() - const variantId = requestImageVariantId(ref, policy) + const variantId = requestImageVariantId(ref, target) const key = String(variantId) let operation = this.requestInflight.get(key) if (operation?.controller.signal.aborted) { @@ -267,9 +270,9 @@ export class LocalAttachmentStore extends AttachmentStore { if (operation === undefined) { const shared = new SharedRequest(sharedSignal => this.compression.run(async () => { const request = await readRequestImageFile( - this.root, + this.cacheRoot, stored ?? await this.readImage(ref, sharedSignal), - policy, + target, sharedSignal, ) return request diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index 237dc5811f..ca5016a1cf 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -4,11 +4,11 @@ import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import sharp, { type Sharp } from 'sharp' -import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment' +import { AttachmentError, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageMediaType, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' @@ -22,7 +22,7 @@ import { import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' /** Transform version included in every cache and upload-index identity. */ -export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5' +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v6' interface EncodedRequestImage { data: Uint8Array @@ -46,17 +46,19 @@ function checkedInteger(value: number, name: string): number { return value } -function validatePolicy(policy: ImageRequestPolicy): void { - checkedInteger(policy.maxPixels, 'Image request maxPixels') - checkedInteger(policy.maxBytes, 'Image request maxBytes') +function validateTarget(target: ImageRequestTarget): void { + checkedInteger(target.width, 'Image request width') + checkedInteger(target.height, 'Image request height') + checkedInteger(target.maxBytes, 'Image request maxBytes') } -function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string { +function descriptor(attachment: ImageAttachmentRef, target: ImageRequestTarget): string { return JSON.stringify({ transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, attachmentId: attachment.attachmentId, - routePixelBudget: policy.maxPixels, - encodedByteBudget: policy.maxBytes, + targetWidth: target.width, + targetHeight: target.height, + encodedByteBudget: target.maxBytes, encoding: { webpQualities: IMAGE_ENCODING_QUALITIES, webpEffort: WEBP_ENCODING_EFFORT, @@ -68,21 +70,23 @@ function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): } /** - * Complete deterministic identity for one attachment and route-owned request policy. + * Complete deterministic identity for one attachment and route-chosen request target. * @param attachment - provider-independent durable normalized attachment reference. - * @param policy - route-owned pixel and byte policy. + * @param target - route-chosen dimensions and byte target. * @returns branded digest over every request transform input. */ export function requestImageVariantId( attachment: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, ): ReturnType { - return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`) + return ImageVariantId(`sha256:${digest(descriptor(attachment, target))}`) } -function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp { +/** Resize by the source long edge only, so the encoder derives the short edge as the route predicts. */ +function pipeline(attachment: StoredImageAttachment, target: ImageRequestTarget): Sharp { + const byWidth = attachment.ref.width >= attachment.ref.height return sourcePipeline(attachment) - .resize({ width, height, fit: 'inside', withoutEnlargement: true }) + .resize({ ...byWidth ? { width: target.width } : { height: target.height }, withoutEnlargement: true }) } function sourcePipeline(attachment: StoredImageAttachment): Sharp { @@ -91,13 +95,12 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp { async function createRequestImage( attachment: StoredImageAttachment, - policy: ImageRequestPolicy, + target: ImageRequestTarget, hasAlpha: boolean, ): Promise { - const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) - if (dimensions.width === attachment.ref.width - && dimensions.height === attachment.ref.height - && attachment.data.byteLength <= policy.maxBytes) { + if (target.width >= attachment.ref.width + && target.height >= attachment.ref.height + && attachment.data.byteLength <= target.maxBytes) { return { data: attachment.data, mediaType: attachment.ref.mediaType, @@ -106,8 +109,8 @@ async function createRequestImage( } } const encodedVersion = await encodeFirstWithinLimit( - encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha), - policy.maxBytes, + encodingLadder(pipeline(attachment, target), hasAlpha), + target.maxBytes, ) return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion } @@ -118,17 +121,15 @@ function cachePath(root: string, hash: string): string { async function readCached( path: string, - attachment: StoredImageAttachment, - policy: ImageRequestPolicy, + target: ImageRequestTarget, expectedAlpha: boolean, signal?: AbortSignal, ): Promise { try { const data = new Uint8Array(await readFile(path, { signal })) const detected = await probeImage(data) - const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) if (detected.depth !== 'uchar' || detected.space !== 'srgb' - || detected.width > maximum.width || detected.height > maximum.height + || detected.width > target.width || detected.height > target.height || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } } catch (error: unknown) { @@ -166,27 +167,27 @@ async function writeCached(path: string, data: Uint8Array): Promise { } /** - * Generate or reuse one request image below the local attachment root. - * @param root - absolute versioned attachment storage root. + * Generate or reuse one request image below the local attachment cache root. + * @param root - absolute attachment cache root; variants use its `request-images` child. * @param attachment - verified normalized attachment bytes and reference. - * @param policy - exact route request-image policy. + * @param target - exact route-chosen dimensions and byte target; a target above the source keeps the source size. * @param signal - optional cancellation for cache I/O and image transformation. * @returns verified request bytes and deterministic variant identity. */ export async function readRequestImageFile( root: string, attachment: StoredImageAttachment, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() - validatePolicy(policy) + validateTarget(target) const source = await probeImage(attachment.data) - const variantId = requestImageVariantId(attachment.ref, policy) + const variantId = requestImageVariantId(attachment.ref, target) const hash = String(variantId).slice('sha256:'.length) const path = cachePath(root, hash) - const cached = await readCached(path, attachment, policy, source.hasAlpha, signal) - const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha) + const cached = await readCached(path, target, source.hasAlpha, signal) + const created = cached ?? await createRequestImage(attachment, target, source.hasAlpha) const version = cached ?? (created.data === attachment.data ? { ...created, hasAlpha: source.hasAlpha } : await verifyRequestImage(created, source.hasAlpha)) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 5711c6d35d..00a427e16e 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -85,7 +85,7 @@ describe('local attachment service', () => { String(ref.attachmentId).slice('sha256:'.length), )) await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) - const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) + const request = await service.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1024 }) expect(request).not.toHaveProperty('access') const fileData = Uint8Array.of(0, 1, 2, 255) diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts index 32bc005c94..693b36f737 100644 --- a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -38,7 +38,7 @@ describe('request image verification', () => { const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) control.mismatch = true - await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) + await expect(attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 })) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED', message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 821480d028..bd27d0bf9d 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -9,10 +9,14 @@ import LocalAttachmentStore from '../src/index.ts' const homes: string[] = [] -async function store(): Promise { +async function home(): Promise { const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-image-')) homes.push(dshHome) - return new LocalAttachmentStore(new Context(), { dshHome }) + return dshHome +} + +async function store(): Promise { + return new LocalAttachmentStore(new Context(), { dshHome: await home() }) } async function image(width: number, height: number): Promise { @@ -39,16 +43,50 @@ async function complexOpaqueAlphaImage(width: number, height: number): Promise { + vi.unstubAllEnvs() await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) }) describe('local request-image cache', () => { + it('rebuilds a cleared cache without moving or losing durable attachments', async () => { + const fallbackHome = await home() + vi.stubEnv('DSH_HOME', fallbackHome) + try { + const dshHome = await home() + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) + const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) + const stored = await attachments.readImage(attachment) + const fileData = Uint8Array.of(0, 1, 2, 255) + const file = await attachments.saveFile({ data: fileData, name: 'notes.bin' }) + const policy = { width: 22, height: 11, maxBytes: 4_096 } + const initial = await attachments.readImageRequest(attachment, policy) + const hash = String(initial.variantId).slice('sha256:'.length) + const cacheRoot = join(dshHome, 'cache') + const path = join(cacheRoot, 'attachments', 'request-images', hash.slice(0, 2), hash) + + expect(attachments.root).toBe(join(dshHome, 'attachments', 'v1')) + await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + await expect(readFile(join(attachments.root, 'request-images', hash.slice(0, 2), hash))) + .rejects.toMatchObject({ code: 'ENOENT' }) + await rm(cacheRoot, { recursive: true }) + + const reopened = new LocalAttachmentStore(new Context(), { dshHome }) + await expect(reopened.readImage(attachment)).resolves.toEqual(stored) + await expect(readFile(reopened.fileHostPath(file))).resolves.toEqual(Buffer.from(fileData)) + await expect(reopened.readImageRequest(attachment, policy)).resolves.toEqual(initial) + await expect(readFile(path)).resolves.toEqual(Buffer.from(initial.data)) + await expect(readdir(fallbackHome)).resolves.toEqual([]) + } finally { + vi.unstubAllEnvs() + } + }) + it('passes through an in-budget attachment and composes ordered request reads', async () => { const attachments = await store() const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' }) const firstStored = await attachments.readImage(first) - const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 } + const policy = { width: 8, height: 8, maxBytes: 1024 * 1024 } const request = await attachments.readImageRequest(first, policy) const batch = await Promise.all([first, second].map( @@ -59,21 +97,61 @@ describe('local request-image cache', () => { expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) }) - it('rejects invalid request policies', async () => { + it('rejects invalid request targets', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) - await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 })) - .rejects.toThrow('Image request maxPixels must be a positive integer') - await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 })) + await expect(attachments.readImageRequest(attachment, { width: 0, height: 4, maxBytes: 100 })) + .rejects.toThrow('Image request width must be a positive integer') + await expect(attachments.readImageRequest(attachment, { width: 8, height: 1.5, maxBytes: 100 })) + .rejects.toThrow('Image request height must be a positive integer') + await expect(attachments.readImageRequest(attachment, { width: 8, height: 4, maxBytes: 0 })) .rejects.toThrow('Image request maxBytes must be a positive integer') }) + it('resizes by the long edge to the exact target and keys the cache by target', async () => { + const attachments = await store() + const maxBytes = 2 * 1024 * 1024 + const square = await attachments.saveImage({ data: await image(2048, 2048), mediaType: 'image/png' }) + const small = await attachments.saveImage({ data: await image(800, 800), mediaType: 'image/png' }) + const thin = await attachments.saveImage({ data: await image(8000, 40), mediaType: 'image/png' }) + const wide = await attachments.saveImage({ data: await image(1920, 1080), mediaType: 'image/png' }) + const tall = await attachments.saveImage({ data: await image(1080, 1920), mediaType: 'image/png' }) + + const squareRequest = await attachments.readImageRequest(square, { width: 1302, height: 1302, maxBytes }) + const smallRequest = await attachments.readImageRequest(small, { width: 800, height: 800, maxBytes }) + const thinRequest = await attachments.readImageRequest(thin, { width: 4096, height: 20, maxBytes }) + const wideRequest = await attachments.readImageRequest(wide, { width: 1708, height: 961, maxBytes }) + const tallRequest = await attachments.readImageRequest(tall, { width: 961, height: 1708, maxBytes }) + const smaller = await attachments.readImageRequest(square, { width: 1024, height: 1024, maxBytes }) + const enlarged = await attachments.readImageRequest(thin, { width: 9000, height: 45, maxBytes }) + + expect(squareRequest).toMatchObject({ width: 1302, height: 1302, mediaType: 'image/jpeg' }) + expect(smallRequest).toMatchObject({ width: 800, height: 800, mediaType: 'image/png' }) + expect(smallRequest.data).toEqual((await attachments.readImage(small)).data) + expect(thinRequest).toMatchObject({ width: 4096, height: 20 }) + expect(wideRequest).toMatchObject({ width: 1708, height: 961 }) + expect(tallRequest).toMatchObject({ width: 961, height: 1708 }) + expect(enlarged).toMatchObject({ width: 8000, height: 40 }) + expect(smaller.variantId).not.toBe(squareRequest.variantId) + expect(enlarged.variantId).not.toBe(thinRequest.variantId) + }) + + it('encodes the rounded short edge at the route target', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ data: await image(1224, 1429), mediaType: 'image/png' }) + const request = await attachments.readImageRequest(attachment, { + width: 1187, height: 1386, maxBytes: 2 * 1024 * 1024, + }) + expect(request).toMatchObject({ width: 1187, height: 1386 }) + await expect(sharp(request.data).metadata()).resolves.toMatchObject({ width: 1187, height: 1386 }) + }) + it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => { const attachments = await store() const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 }) + const request = await attachments.readImageRequest(attachment, { width: 1, height: 1, maxBytes: 1 }) expect(request.mediaType).toBe('image/jpeg') expect(request.bytes).toBeGreaterThan(1) @@ -81,12 +159,13 @@ describe('local request-image cache', () => { }) it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { - const attachments = await store() + const dshHome = await home() + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) - const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } + const policy = { width: 22, height: 11, maxBytes: 4_096 } const initial = await attachments.readImageRequest(attachment, policy) const hash = String(initial.variantId).slice('sha256:'.length) - const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) + const path = join(dshHome, 'cache', 'attachments', 'request-images', hash.slice(0, 2), hash) const noisyPixels = new Uint8Array(64 * 64 * 3) let state = 0x2545f491 for (let index = 0; index < noisyPixels.length; index += 1) { @@ -132,10 +211,10 @@ describe('local request-image cache', () => { data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', }) - const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) - const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }) + const squareRequest = await attachments.readImageRequest(square, { width: 800, height: 800, maxBytes: 1024 * 1024 }) + const wideRequest = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 }) + const repeated = await attachments.readImageRequest(wide, { width: 1130, height: 565, maxBytes: 1024 * 1024 }) + const low = await attachments.readImageRequest(wide, { width: 724, height: 362, maxBytes: 1024 * 1024 }) expect(squareRequest).toMatchObject({ width: 800, height: 800 }) expect(wideRequest).toMatchObject({ width: 1130, height: 565 }) @@ -175,8 +254,8 @@ describe('local request-image cache', () => { const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' }) const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' }) - const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 }) - const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) + const photoRequest = await attachments.readImageRequest(photo, { width: 128, height: 128, maxBytes: 1024 * 1024 }) + const alphaRequest = await attachments.readImageRequest(alpha, { width: 128, height: 128, maxBytes: 4_096 }) expect(photoRequest.mediaType).toBe('image/jpeg') expect(alphaRequest.mediaType).toBe('image/webp') @@ -192,7 +271,7 @@ describe('local request-image cache', () => { }).toColourspace('rgb16').png().toBuffer()) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) @@ -206,7 +285,7 @@ describe('local request-image cache', () => { const source = await complexOpaqueAlphaImage(64, 32) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { width: 22, height: 11, maxBytes: 1024 * 1024 }) expect(request.mediaType).toBe('image/webp') await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false }) @@ -228,7 +307,7 @@ describe('local request-image cache', () => { }).png().toBuffer()) const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) - const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const request = await attachments.readImageRequest(attachment, { width: 800, height: 800, maxBytes: 1024 * 1024 }) expect(request).toMatchObject({ width: 800, height: 800 }) expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) @@ -241,7 +320,7 @@ describe('local request-image cache', () => { }) const run = vi.spyOn(CompressionLimiter.prototype, 'run') const controller = new AbortController() - const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) const completed = attachments.readImageRequest(attachment, policy) @@ -271,7 +350,7 @@ describe('local request-image cache', () => { const controller = new AbortController() const request = attachments.readImageRequest( attachment, - { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + { width: 1130, height: 565, maxBytes: 1024 * 1024 }, controller.signal, ) await vi.waitFor(() => { @@ -304,7 +383,7 @@ describe('local request-image cache', () => { return actualRead(ref, signal) }) const controller = new AbortController() - const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + const policy = { width: 1130, height: 565, maxBytes: 1024 * 1024 } const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) await vi.waitFor(() => { expect(calls).toBe(1) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 6a5e2093df..87d9bd1b78 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: 35fde71833fa13b26662244cfa68cec17b90b663 -README.zh.md: b0e98d1e4a0c35082a8eef23f69659f47916d3f7 +README.md: d2a667e2d12deadc5e82a83cca6565f0b5a6aae0 +README.zh.md: b5598eb8ccca39d8b5be346c585fd84d3dd38468 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 35fde71833..d2a667e2d1 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -66,13 +66,13 @@ This section explains the design decisions behind the seam and the service opera - **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization. - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. -- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. +- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path. - **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, target dimensions, byte target, and encoder settings. The pure `requestImageDimensions` and `longEdgeDimensions` exports compute aspect-preserving dimensions from a total-pixel budget or an exact long edge, so routes and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index b0e98d1e4a..b5598eb8cc 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -66,13 +66,13 @@ kind: "package-reference" - **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。 - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象不会通过校验。 -- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 +- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 - **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host 提示词消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、目标尺寸、字节目标及编码参数。纯函数导出 `requestImageDimensions` 与 `longEdgeDimensions` 按总像素预算或精确长边计算保持宽高比的尺寸,使路由与请求定价共享同一套几何计算。`imageHostPath` 只向需要把该位置映射到执行环境的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现负责管理压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 3ca33ec97c..e030ce0e71 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 5df42a8bda..d5854336db 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -10,7 +10,7 @@ import type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveFileAttachment, SaveFileStreamAttachment, @@ -22,7 +22,8 @@ export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedFile, admitEncodedImages } from './admission.ts' -export { requestImageDimensions } from './request-projection.ts' +export { longEdgeDimensions, requestImageDimensions } from './request-projection.ts' +export type { ProjectedDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, @@ -32,7 +33,7 @@ export type { FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, ImageMediaType, PromptContentPart, RequestImageAttachment, @@ -241,18 +242,18 @@ export abstract class AttachmentStore extends Service { /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. - * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param target - route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output. * @param signal - optional cancellation. * @returns request bytes and the cache/upload identity covering every transform input. */ readImageRequest( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ): Promise { signal?.throwIfAborted() void ref - void policy + void target return Promise.reject(new AttachmentError( 'The mounted attachment provider cannot derive model-request images.', 'ATTACHMENT_PROJECTION_UNSUPPORTED', diff --git a/packages/attachment/attachment/src/request-projection.ts b/packages/attachment/attachment/src/request-projection.ts index ac9a56c983..4e809a3632 100644 --- a/packages/attachment/attachment/src/request-projection.ts +++ b/packages/attachment/attachment/src/request-projection.ts @@ -1,8 +1,14 @@ /** - * Pure request-projection geometry shared by attachment providers and - * provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection + * Pure request-projection geometry shared by model routes and provider-side + * request pricing. @module @deepseek-ai/dsh-attachment/request-projection */ +/** Integer width and height of one projected image. */ +export interface ProjectedDimensions { + width: number + height: number +} + /** * Compute aspect-preserving integer dimensions within a hard total-pixel budget. * @param width - positive source width. @@ -14,7 +20,7 @@ export function requestImageDimensions( width: number, height: number, maxPixels: number, -): { width: number; height: number } { +): ProjectedDimensions { const scale = Math.min(1, Math.sqrt(maxPixels / (width * height))) if (scale === 1) return { width, height } if (width >= height) { @@ -34,3 +40,23 @@ export function requestImageDimensions( } return { width: projectedWidth, height: projectedHeight } } + +/** + * Compute aspect-preserving integer dimensions with an exact long edge; the + * short edge rounds to the nearest pixel, as an encoder resize by the + * long edge alone. + * @param width - positive source width. + * @param height - positive source height. + * @param longEdge - positive target for the longer source edge. + * @returns the target dimensions; a long edge at or above the source returns the source unchanged. + */ +export function longEdgeDimensions( + width: number, + height: number, + longEdge: number, +): ProjectedDimensions { + if (longEdge >= Math.max(width, height)) return { width, height } + return width >= height + ? { width: longEdge, height: Math.max(1, Math.round(longEdge * height / width)) } + : { width: Math.max(1, Math.round(longEdge * width / height)), height: longEdge } +} diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index a69b2363dc..455d6c9de0 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -132,10 +132,12 @@ export interface StoredImageAttachment { data: Uint8Array } -/** Deterministic request-image policy selected by one exact model route. */ -export interface ImageRequestPolicy { - /** Maximum width multiplied by height after aspect-preserving projection. */ - maxPixels: number +/** Deterministic request-image target selected by one exact model route for one attachment. */ +export interface ImageRequestTarget { + /** Target width in pixels; a target above the source keeps the source width. */ + width: number + /** Target height in pixels; a target above the source keeps the source height. */ + height: number /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ maxBytes: number } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 923c1a4360..9ce59d2d48 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -8,7 +8,7 @@ import AttachmentStore, { isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, - type ImageRequestPolicy, + type ImageRequestTarget, type RequestImageAttachment, type SaveFileAttachment, type SaveImageAttachment, @@ -56,7 +56,7 @@ class RecordingStore extends AttachmentStore { override readImageRequest( ref: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, ): Promise { this.calls.push(`request:${ref.name}`) return Promise.resolve({ @@ -154,12 +154,12 @@ describe('AttachmentStore.readImageRequest', () => { it('reports unsupported request projection while preserving cancellation', async () => { const store = new UnsupportedProjectionStore(new Context()) const ref = await new RecordingStore(new Context()).saveImage(image(1)) - await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) + await expect(store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) const controller = new AbortController() const reason = new Error('cancel unsupported projection') controller.abort(reason) - expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) + expect(() => store.readImageRequest(ref, { width: 1, height: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) it('rejects generic-file storage and exposes no provider-owned host path by default', async () => { diff --git a/packages/attachment/attachment/tests/request-projection.spec.ts b/packages/attachment/attachment/tests/request-projection.spec.ts index 5e8a740779..2db1578863 100644 --- a/packages/attachment/attachment/tests/request-projection.spec.ts +++ b/packages/attachment/attachment/tests/request-projection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { requestImageDimensions } from '../src/index.ts' +import { longEdgeDimensions, requestImageDimensions } from '../src/index.ts' describe('request image dimensions', () => { it.each([ @@ -27,3 +27,17 @@ describe('request image dimensions', () => { expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 }) }) }) + +describe('long-edge dimensions', () => { + it('keeps the long edge exact and rounds the short edge', () => { + expect(longEdgeDimensions(8000, 40, 4096)).toEqual({ width: 4096, height: 20 }) + expect(longEdgeDimensions(10_000, 100, 4096)).toEqual({ width: 4096, height: 41 }) + expect(longEdgeDimensions(1080, 2400, 1862)).toEqual({ width: 838, height: 1862 }) + expect(longEdgeDimensions(1, 9000, 4096)).toEqual({ width: 1, height: 4096 }) + }) + + it('never enlarges a source at or below the long edge', () => { + expect(longEdgeDimensions(320, 240, 320)).toEqual({ width: 320, height: 240 }) + expect(longEdgeDimensions(320, 240, 4096)).toEqual({ width: 320, height: 240 }) + }) +}) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 31090f0c34..c0c9bc96de 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 59dafbb1e2e5adba5dccdcae08665295290dfaef -README.zh.md: f6f1e7aa36b9a407e170246b7752fd2963d0dc5b +README.md: 02709d2079ddc7aeaa2e2ed112beacf65a8d7c3c +README.zh.md: b044a8820ac873c96a945017c15a3ed3c71025d8 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 59dafbb1e2..02709d2079 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -With that entry point, success looks like a running app with every plugin active; failure is never silent — one labelled line names the failing plugin and the stage, and the process exits nonzero. The app context is torn down before the error is reported, so nothing keeps running half-started. +With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. ### Profiles @@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home: - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead. -Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. +Profiles with `patchReload: live` watch both user patch files and apply the [reload failure policy](#startup-and-reload-failures). A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal. @@ -92,9 +92,28 @@ await mutatePatchFile(file, (document) => { Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump. -### What you see when startup fails + +### Startup and reload failures -Startup failure is a single labelled line plus a nonzero exit — never a silent hang or a raw stack dump. The message names the failing plugin; a plugin that threw keeps its original error, and an entry that never started is reported with the services it was waiting for. +After the Loader settles, app-boot reports optional failures as warnings and rejects startup if an enabled required entry cannot activate. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update. + +| Failure pattern | Optional entry at startup | Required entry at startup | Later configuration HMR | +|---|---|---|---| +| Root config or required overlay is missing, unreadable, malformed, or contains invalid entries | Stop startup | Stop startup | Malformed or invalid live patches are rejected without changing the running configuration; a valid edit applies | +| Module import fails or module evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a corrected import can activate | +| Plugin config schema validation fails | Warn; continue | Stop startup | A new entry stays inactive; an existing entry retains its prior instance and config; a valid correction applies | +| Config `!!js` evaluation throws | Warn; continue | Stop startup | Report the error; keep successful siblings; a valid correction can activate | +| `disabled: !!js` evaluation throws | Warn; continue | Stop startup | Report the evaluation error rather than treating the entry as disabled; a valid correction can activate | +| Synchronous `apply()` throws | Warn; continue | Stop startup | Report the error; keep successful siblings; corrected config can activate | +| Asynchronous `apply()` throws | Warn after settlement; continue | Stop startup after settlement | Report the error after settlement; keep successful siblings; corrected config can activate | +| An injected service is unavailable | Warn; continue while the entry waits for its dependencies | Stop startup | Keep the entry waiting; adding the missing provider can activate it | +| HTTP port binding fails | Warn; continue without that endpoint | Stop startup | Keep the process running without the failed endpoint; corrected config can restore it | +| Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id | +| Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit | + +The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes. + +The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) and [startup acceptance](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) verify these outcomes through the shipped Web profile; [app-boot tests](tests/app-boot.spec.ts) also exercise root Include failures. If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it. @@ -115,14 +134,11 @@ This section explains how the outcomes above are realized and points at the code ### Design notes - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution. -- **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution. -- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing, so `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group` under the ids the bundle declares; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, a group that updates drops the records of rows it no longer configures and one that unmounts drops them all, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending. -- **Row ids are owned, not rewritten.** Entry ids are unique per tree and a `create()` that finds an existing id re-parents that entry instead of rejecting, so `composeProfileStack` decides ownership before anything mounts: built-in and boot-staged layers claim first and a duplicate among them fails the boot, a contained bundle that collides is left out whole, a user insert of a taken id is dropped, and every such row is a `conflict` record in `pluginFailures`. A config override may restate a row under the group that already holds it; the same id twice in one config list, or set under another group, counts as declared twice — the Loader would reject the first at mount and silently move the second — so a built-in layer fails the boot and a contained bundle is left out. Boot, live recomposition, and `--dump-config` compose through the same function. -- **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines. -- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process that reports over an IPC channel, so a package that throws, exits, hangs, prints at import, or brings its own copy of cordis costs one child and yields a record with the reason — the cordis check compares package directories, through links and through the proxy a packaged executable writes, so `src` and `lib` resolutions of one package agree; the child's report and the cached record are validated field by field before either is trusted. The child runs with the harness's credential-shaped variables scrubbed (`SENSITIVE_ENV_PATTERN` from `dsh-launch-environment`), its report is the one message that echoes a per-run token it removed from its environment before importing — the imported code finds no `process.send` either — the failure text keeps the last 16 KiB of its stderr, and the probe settles only once the child closed. It calls a package a `plugin` only when the package declares itself to dsh — a `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main export is plugin-shaped; a bare function export (`lodash`) is a `library`. Records are cached under the profile's `.dsh-plugins/` with a format number, so a record an older probe wrote is probed again rather than trusted. +- **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. +- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint. - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. -- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. -- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. +- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success. +- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. Cyclic causes terminate diagnostic traversal without replacing the original cause. ### Helper behavior @@ -166,7 +182,7 @@ Read these pages when the package-level contract is not enough. They move from t - [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`). - [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior. - [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design. -- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of transaction behavior and native filesystem delivery. +- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of live-patch behavior and native filesystem delivery. ----- diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index f6f1e7aa36..b044a8820a 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -有了这个入口,成功就是每个插件都已激活的运行中应用;失败绝不会悄无声息——一行带标签的信息点名失败的插件与阶段,进程以非零码退出。错误上报前会先拆卸应用上下文,因此不会留下半启动的残留。 +有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 ### Profile @@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。 -带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。 +带 `patchReload: live` 的 profile 会监视两份用户 patch 文件,并应用[重载失败策略](#startup-and-reload-failures)。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。 @@ -92,9 +92,28 @@ await mutatePatchFile(file, (document) => { 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。 -### 启动失败时你会看到什么 + +### 启动与重载失败 -启动失败是一行带标签的信息加非零退出码——绝不是静默卡死或原始堆栈转储。信息会点名失败的插件;抛错的插件保留原始错误,从未启动的条目会连同它等待的服务一起报告。 +Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的 required 条目无法激活,则拒绝启动。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。 + +| 失败模式 | Optional 条目启动时 | Required 条目启动时 | 后续配置 HMR | +|---|---|---|---| +| 根配置或必需 overlay 缺失、不可读、格式错误,或包含无效条目 | 终止启动 | 终止启动 | 拒绝格式错误或无效的实时 patch,不改变运行中的配置;有效修改可以应用 | +| 模块 import 失败或模块求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正 import 后可以激活 | +| 插件配置 schema 校验失败 | 警告;继续 | 终止启动 | 新条目保持未激活;现有条目保留原实例与配置;有效修正可以应用 | +| 配置 `!!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;有效修正后可以激活 | +| `disabled: !!js` 求值抛出异常 | 警告;继续 | 终止启动 | 报告求值错误,不将条目当作已禁用;有效修正后可以激活 | +| 同步 `apply()` throw | 警告;继续 | 终止启动 | 报告错误;保留成功的兄弟插件;修正配置后可以激活 | +| 异步 `apply()` throw | 结算后警告;继续 | 结算后终止启动 | 结算后报告错误;保留成功的兄弟插件;修正配置后可以激活 | +| 注入的服务不可用 | 警告;继续,条目等待依赖 | 终止启动 | 条目继续等待;补上缺失的提供方后可以激活 | +| HTTP 端口绑定失败 | 警告;继续,但该端点不可用 | 终止启动 | 进程继续运行,但失败的端点不可用;修正配置后可以恢复 | +| 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 | +| 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 | + +上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。 + +[Web 进程矩阵](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)和[启动验收测试](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)通过随附 Web profile 验证这些结果;[app-boot 测试](tests/app-boot.spec.ts)还覆盖根 Include 失败。 如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。 @@ -115,14 +134,11 @@ await mutatePatchFile(file, (document) => { ### 设计说明 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。 -- **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 -- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,因此 `composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行按组合包声明的 id 包进一个 `cordis:contained-group`;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,组更新时丢掉配置里不再有的行的记录,卸载时全部丢掉,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。 -- **行 id 归属而非改写。** entry id 在整棵树内唯一,而 `create()` 遇到已有 id 时会把那个 entry 挪到自己名下而不是 reject,所以 `composeProfileStack` 在任何行挂载之前先判定归属:内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败;撞名的受控组合包整层排除;用户层插入已被占用的 id 时该行丢弃;每一条被排除的行都是 `pluginFailures` 里的一条 `conflict` 记录。config 覆盖可以在已持有某行的组下重述它;同一 config 列表里出现两次、或改放到别的组下,都算声明了两次——前者会在挂载时被 Loader 拒绝,后者会被静默挪走——因此内置层启动失败、受控组合包整层排除。启动、运行时重组与 `--dump-config` 走同一个函数。 -- **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。 -- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它并经 IPC 通道接收报告,因此抛错、退出、挂起、import 时打印或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录——cordis 判定比较的是包目录,穿过符号链接与打包可执行文件写出的代理包,因此同一个包的 `src` 与 `lib` 解析结果一致;子进程的报告与缓存记录都逐字段校验之后才被信任。子进程拿到的是剔除了密钥形态变量的宿主环境(`dsh-launch-environment` 的 `SENSITIVE_ENV_PATTERN`),报告是唯一回显本次 token 的那条消息,token 在 import 之前就从子进程环境里删掉、被 import 的代码也找不到 `process.send`;失败文本只留 stderr 的最后 16 KiB;探针要等子进程关闭后才结算。只有包向 dsh 声明了自己——有 `dsh` 段或依赖 `@deepseek-ai/cordis`——且主导出是插件形状时才判为 `plugin`;光是导出一个函数(`lodash`)的包是 `library`。记录缓存在 profile 的 `.dsh-plugins/` 下并带格式号,旧版探针写的记录会重新探测而不是被信任。 -- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 -- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 -- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 +- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 +- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。 +- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 +- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。 +- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因。 ### Helper 行为 @@ -166,7 +182,7 @@ await mutatePatchFile(file, (document) => { - [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。 - [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。 - [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。 -- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——事务行为与原生文件系统投递的验证归属。 +- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——实时 patch 行为与原生文件系统投递的验证归属。 ----- diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 6c92fdd624..31b1aec5ac 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -35,6 +35,7 @@ "dependencies": { "@deepseek-ai/dsh-atomic-write": "workspace:^", "@deepseek-ai/dsh-package-manifest": "workspace:^", + "chokidar": "4.0.3", "js-yaml": "^4.2.0", "resolve.exports": "^2.0.3", "yaml": "^2.9.0" @@ -64,7 +65,6 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/js-yaml": "^4.0.9", - "@deepseek-ai/cordis": "workspace:^", - "chokidar": "4.0.3" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 3f066640d9..e9dc733895 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -19,8 +19,9 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' +import { watchConfig } from './watch-config.ts' import type {} from '@deepseek-ai/dsh-system-prompt' -import { ContainedGroup, containingGroup, ensurePluginFailures, isContainedEntry, pendingMessage } from './contained-group.ts' +import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -254,7 +255,7 @@ export interface UserPatchWatchOptions { } /** - * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include. + * Watch the user patch layer and reapply it to the boot Include without rollback. * @param ctx - settled app context containing the root Include and an active HMR service. * @param options - diagnostic, file, and re-application inputs. * @returns an asynchronous disposer after the exact-path watcher is ready. @@ -279,8 +280,12 @@ export async function watchUserPatches( patches: loadOptionalPatches(binName, filename) ?? [], }, }) + await ctx.loader.await() + await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await()))) + const failures = await inactiveEntries(ctx) + if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd()) }) - const register = hmr.registerConfig(filename, reapply) + const register = watchConfig(ctx, filename, hmr.config, reapply) try { return await register } catch (error) { @@ -487,7 +492,7 @@ export function rootIncludeEntry(ctx: Context): Entry | undefined { * names; relative names continue to resolve beside the configuration file. * @returns the created root Include entry, or `undefined` when a surface * disposed the whole tree (taking the Loader service with it) while the - * transactional create was still settling entry lifecycle. + * entry creation was in flight. */ export async function mountRootInclude( ctx: Context, @@ -590,7 +595,7 @@ export const FAIL_LOUD_RELEASE_TIMEOUT_MS = 2_000 /** * Install before boot to turn a late unhandled plugin-init rejection into one * labelled stderr diagnostic and `exit(1)`. A rejection already included by - * {@link assertEntriesActivated} is ignored during its process checkpoint; + * {@link auditStartupEntries} is ignored during its process checkpoint; * every other rejection remains fatal. Stdout remains untouched for ACP; the * returned function removes the handler. * @@ -700,22 +705,6 @@ export function installRuntimeGuards( } } -/** - * After the tree settles, reject entries with no fiber and name every plugin - * whose module failed to resolve. Disabled entries are the only valid - * fiber-less state. - * @param ctx - the settled context whose loader entries to audit. - * @param binName - the diagnostic prefix on the thrown error. - */ -export function assertEntriesLoaded(ctx: Context, binName: string): void { - const failed = [...ctx.loader.entries()] - .filter(entry => entry.fiber === undefined && !entry.disabled && !isContainedEntry(entry)) - if (failed.length > 0) { - const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`) - } -} - /** * Value mirrors used because Cordis's const enum has no runtime object to import. * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and @@ -725,123 +714,146 @@ const FIBER_PENDING = 0 as FiberState.PENDING const FIBER_ACTIVE = 2 as FiberState.ACTIVE const FIBER_FAILED = 3 as FiberState.FAILED -/** Render a thrown plugin value without discarding an Error's original stack. */ +/** + * Entry ids whose presence defines a usable DSH application. + * + * The list is global rather than profile metadata. Missing or disabled ids do + * not affect startup; an enabled listed entry must activate. The list covers + * shared Agent execution, application endpoints, and Web bootstrap/transport. + */ +const requiredStartupEntryIds = new Set([ + 'agent-loop', + 'webserver', + 'modules', + 'connection', + 'headless-runner', + 'acp', + 'sdk-jsonrpc-server', +]) + +/** Render plugin stacks, nested causes, and aggregate member failures once per error. */ function formatActivationError(error: unknown): string { - return error instanceof Error ? error.stack ?? error.message : String(error) -} - -/** The tree-wide id of the contained group that isolates a row, whatever plain groups sit between. */ -function owningGroupId(entry: Entry): string { - /* v8 ignore next -- only contained entries are recorded and their group runs as a Loader entry; the fallback keeps the type total */ - return containingGroup(entry)?.ctx.fiber.entry?.id ?? '' -} - -/** - * Reject a settled Loader tree when an enabled entry failed or remains inactive. - * Plugin failures include the original thrown stack; pending entries name their - * unresolved services because no plugin error exists for that state. Active - * entries require no further wait; only failed fibers are awaited to recover - * their private rejection reason. - * @param ctx - the settled context whose Loader entries to audit. - * @param binName - the diagnostic prefix on the thrown error. - * @returns nothing when every enabled entry is active. - * @throws after one process rejection checkpoint when an entry failed to - * import, rejected during activation, or did not become active. - */ -export async function assertEntriesActivated(ctx: Context, binName: string): Promise { - assertEntriesLoaded(ctx, binName) - const { failures, builtinPending } = await auditEntries(ctx) - const registry = ctx.get('pluginFailures') - if (failures.length > 0) { - const noun = failures.length === 1 ? 'entry' : 'entries' - // A contained failure is only harmless while nothing built-in depends on - // it: a built-in row left waiting for a service names the isolated - // bundles, because one of them is the likely missing provider. - const isolated = builtinPending ? registry?.list() ?? [] : [] - const hint = isolated.length === 0 - ? '' - : `\n${binName}: isolated bundle failure(s) may be the missing provider — ` - + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} ` - + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud' - throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`) + const details: string[] = [] + const seen = new Set() + function visit(value: unknown): void { + if (!(value instanceof Error)) { + details.push(String(value)) + return + } + if (seen.has(value)) return + seen.add(value) + details.push(value.stack ?? value.message) + if (value.cause !== undefined) visit(value.cause) + if (value instanceof AggregateError) value.errors.forEach(visit) } + visit(error) + return details.join('\n') +} + +interface InactiveEntry { + /** Loader entry used to identify the bootstrap Include and required ids. */ + entry: Entry + /** Complete diagnostic beginning with the entry id and module specifier. */ + diagnostic: string } /** - * Record the state of every contained row after a runtime recomposition — - * a bundle enabled or retried while the tree runs — the way the boot audit - * records it: an active row clears its record, a failed or waiting row is - * recorded. Built-in rows are left to the caller's own policy; nothing here - * throws. - * @param ctx - the context whose Loader entries to walk. - * @returns the diagnostics the boot audit would have raised for built-in rows. + * Collect Loader activation failures and disabled-expression errors. Failed + * fibers are awaited to recover their recorded rejection reason and coalesce + * duplicate Loader notifications through the next process rejection checkpoint. */ -export async function recordContainedStates(ctx: Context): Promise { - return (await auditEntries(ctx)).failures -} - -/** What one walk of the Loader entries found beyond the contained records it wrote. */ -interface EntryAudit { - /** One line per built-in entry that failed or is not active. */ - failures: string[] - /** Whether a built-in entry is waiting for a service. */ - builtinPending: boolean -} - -/** - * Walk every enabled entry: clear or record contained rows, collect built-in - * diagnostics, and pass built-in rejections through the process checkpoint - * so the fail-loud handler does not exit over a rejection the audit reports. - */ -async function auditEntries(ctx: Context): Promise { - const failures: string[] = [] +async function inactiveEntries(ctx: Context): Promise { + const failures: InactiveEntry[] = [] const rejectionReasons: unknown[] = [] - const registry = ctx.get('pluginFailures') - let builtinPending = false for (const entry of ctx.loader.entries()) { - const fiber = entry.fiber - if (fiber === undefined || entry.disabled) continue - const state = fiber.state - if (state === FIBER_ACTIVE) { - registry?.clear(entry.id) + const subject = `${entry.options.id} (${entry.options.name})` + try { + if (entry.disabled) continue + } catch (error) { + failures.push({ entry, diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}` }) continue } - const contained = isContainedEntry(entry) + const fiber = entry.fiber + if (fiber === undefined) { + failures.push({ entry, diagnostic: `${subject}: failed to import` }) + continue + } + const state = fiber.state + if (state === FIBER_ACTIVE) continue if (state === FIBER_FAILED) { try { await fiber.await() } catch (error) { - if (contained) { - registry?.record({ - entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name, - groupId: owningGroupId(entry), stage: 'apply', message: formatActivationError(error), - }) - continue - } rejectionReasons.push(error) - failures.push(`${entry.options.name}: ${formatActivationError(error)}`) + failures.push({ entry, diagnostic: `${subject}: ${formatActivationError(error)}` }) } continue } if (state === FIBER_PENDING) { - const line = pendingMessage(fiber) - if (contained) { - registry?.record({ - entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name, - groupId: owningGroupId(entry), stage: 'inject-pending', message: line, - }) - continue - } - builtinPending = true - failures.push(`${entry.options.name}: ${line}`) + const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) + failures.push({ + entry, + diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`, + }) } else { - failures.push(`${entry.options.name}: fiber state ${String(state)}`) + failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` }) } } - if (rejectionReasons.length > 0) { - await observeLoaderRejectionCheckpoint(rejectionReasons) + if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons) + return failures +} + +/** Render an inactive-entry diagnostic with a count and severity label. */ +function activationDiagnostic( + binName: string, + severity: 'warning' | 'required startup failure', + failures: readonly InactiveEntry[], +): string { + const noun = failures.length === 1 ? 'entry' : 'entries' + const prefix = binName === '' ? '' : `${binName}: ` + return `${prefix}${severity}: ${String(failures.length)} ${noun} did not activate\n${failures.map(failure => failure.diagnostic).join('\n')}\n` +} + +/** + * Apply DSH startup policy to a settled Loader tree. + * + * Inactive entries from the global required list reject startup. Other + * inactive entries produce one warning and leave successful siblings running. + * Required ids absent from the tree, and disabled required entries, are ignored. + * A throwing disabled expression is an entry failure, not a disabled entry. + * The bootstrap Include must activate so unreadable or invalid root config is fatal. + * @param ctx - the settled context whose Loader entries to audit. + * @param binName - the diagnostic prefix on optional-entry warnings. + * @param warn - sink for optional-entry warnings. + * @returns after optional warnings if required startup checks pass. + * @throws when the bootstrap Include or a required entry is inactive or its disabled expression throws. + */ +export async function auditStartupEntries( + ctx: Context, + binName: string, + warn: (line: string) => void = line => void process.stderr.write(line), +): Promise { + const failures = await inactiveEntries(ctx) + const required: InactiveEntry[] = [] + const optional: InactiveEntry[] = [] + for (const failure of failures) { + const target = failure.entry === bootstrapIncludes.get(ctx) + || requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional + target.push(failure) } - return { failures, builtinPending } + if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional)) + if (required.length > 0) { + throw new Error(activationDiagnostic('', 'required startup failure', required).trimEnd()) + } +} + +/** + * Read activation diagnostics after a runtime update. + * @param ctx - the settled Loader context. + * @returns diagnostics for inactive entries. + */ +export async function recordContainedStates(ctx: Context): Promise { + return (await inactiveEntries(ctx)).map(failure => failure.diagnostic) } /** @@ -883,12 +895,11 @@ export function warnNestedFiberFailures( * is statically imported and mounted as the `cordis:include` builtin, loading * through the ambient module pipeline (vite/tsx/plain ESM). The package build * embeds Include while leaving Loader external, so the built include tree and - * host share one Loader peer. Loader - * settlement rejects startup failures, which `boot` wraps after disposing the - * partial context; a missing fiber or never-activating entry is rejected by - * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's - * init rejection with its original stack; later unhandled rejections remain - * covered by {@link installFailLoud}. Built bins need the Loader's native + * host share one Loader peer. Loader settlement drains entry work without + * rejecting the whole tree. The final {@link auditStartupEntries} call rejects + * failures in the global required list and warns about other failed, missing, + * and pending entries while successful siblings remain active. Later unhandled + * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native * helper for bare plugin specifiers; relative specifiers do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute @@ -899,11 +910,12 @@ export function warnNestedFiberFailures( * @param bareModuleBaseUrl - optional installed-host base for bare package * names; use it when the host, rather than the configuration project, owns the * complete plugin set. - * @returns the root context once every entry has started, or as soon as a + * @returns the root context after the initial startup audit, or as soon as a * surface disposed the tree while startup was still in flight. * @throws a labelled error after disposing the partial context — `host * preparation failed` when `prepare` threw before any config-tree entry - * mounted, `plugin tree failed to load` afterwards. + * mounted, `plugin tree failed to load` afterwards. Cyclic causes terminate + * diagnostic traversal without replacing the original cause. */ export async function boot( binName: string, @@ -919,6 +931,11 @@ export async function boot( try { ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' ctx.provide('dshHomePath', dshHomePath) + // Fiber.update() discards the restart promise. Observe it before the + // waterfall returns; activation audits still report the failed fiber. + ctx.on('internal/update', (_config, _noSave, next: () => unknown) => { + void Promise.resolve(next()).catch((error: unknown) => { ctx.logger.error(error) }) + }, { global: true, prepend: true }) await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' @@ -927,12 +944,10 @@ export async function boot( // in flight, before the last entry settles. The Loader service goes with // it, and the activation audit describes a live tree — reading `ctx.loader` // past this point would throw a TypeError over an app that exited exactly - // as asked. Transactional group updates settle - // lifecycle inside the mount, so the teardown can land before it returns; - // re-check after every await. + // as asked. Re-check after settlement before auditing the tree. await ctx.get('loader')?.await() if (ctx.get('loader') === undefined) return ctx - await assertEntriesActivated(ctx, binName) + await auditStartupEntries(ctx, binName) return ctx } catch (cause) { // Root-fiber disposal contains cleanup failures per observer (Cordis @@ -940,13 +955,13 @@ export async function boot( // result, so this await cannot reject and replace `cause`. await ctx.fiber.dispose() const detail = cause instanceof Error ? cause.message : String(cause) - // The transactional Loader wraps a failing entry apply in one message per - // tree layer; every layer's message is folded into `detail` above, and the - // deepest cause is the plugin's own thrown error, whose stack names the - // real failure site — append it so the startup diagnostic preserves the - // original activation error instead of only the wrap chain. + // A wrapper can carry an activation error whose original stack names the failed plugin. let deepest: unknown = cause - while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause + const seen = new Set() + while (deepest instanceof Error && !seen.has(deepest) && deepest.cause !== undefined) { + seen.add(deepest) + deepest = deepest.cause + } const stack = deepest instanceof AggregateError ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' diff --git a/packages/boot/app-boot/src/watch-config.ts b/packages/boot/app-boot/src/watch-config.ts new file mode 100644 index 0000000000..ec761ee6a2 --- /dev/null +++ b/packages/boot/app-boot/src/watch-config.ts @@ -0,0 +1,89 @@ +/** Exact-path watching for live profile patch files outside Cordis module roots. */ +import { dirname, relative, resolve } from 'node:path' +import { realpath, stat } from 'node:fs/promises' +import { watch, type ChokidarOptions } from 'chokidar' +import type { Context } from '@deepseek-ai/cordis' + +const registrations = new WeakMap>() + +async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { + let root = dirname(filename) + let depth = 0 + while (true) { + try { + if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) + const canonicalRoot = await realpath(root) + return { filename: resolve(canonicalRoot, relative(root, filename)), root: canonicalRoot, depth } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + const parent = dirname(root) + if (parent === root) throw error + root = parent + depth += 1 + } + } +} + +/** + * Watch one patch path, including missing parents, and serialize refresh callbacks. + * @param ctx Context that owns watcher disposal and receives refresh failures. + * @param filename Absolute patch-file path. + * @param options Deployment watcher options inherited from the HMR configuration. + * @param refresh Callback for additions, changes, and removals. + * @returns A disposer that closes the watcher and drains its current refresh. + * @throws When path resolution, watcher startup, or effect registration fails. + */ +export async function watchConfig( + ctx: Context, filename: string, options: ChokidarOptions, refresh: () => Promise | void, +): Promise<() => Promise> { + const target = await findWatchRoot(filename) + const paths = registrations.get(ctx) ?? new Set() + registrations.set(ctx, paths) + if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`) + const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options + const watcher = watch(target.root, { + ...watchOptions, depth: target.depth, ignoreInitial: false, + }) + paths.add(target.filename) + const state = { dirty: false } + let running: Promise | undefined + const onChange = (path: string) => { + const observed = resolve(path) + if (observed !== filename && observed !== target.filename) return + state.dirty = true + if (running) return + running = (async () => { + while (state.dirty) { + state.dirty = false + try { + await refresh() + } catch (reason) { + const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) + ctx.logger.warn('config reload at %C failed', filename) + ctx.logger.warn(error) + } + } + })().finally(() => { running = undefined }) + } + watcher.on('add', onChange) + watcher.on('change', onChange) + watcher.on('unlink', onChange) + const ready = Promise.withResolvers() + let pending = true + watcher.once('ready', () => { pending = false; ready.resolve() }) + watcher.on('error', (error) => { + if (pending) { pending = false; ready.reject(error) } else { ctx.logger.warn(error) } + }) + const dispose = async () => { + await watcher.close() + paths.delete(target.filename) + await running + } + try { + await ready.promise + return ctx.effect(() => dispose, 'app-boot.watchConfig()') + } catch (error) { + await dispose() + throw error + } +} diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 23b79ee7cb..5fc1dedd47 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { - addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, + addHarnessSourceSection, auditStartupEntries, boot, FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION, installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' @@ -419,11 +419,11 @@ describe('installFailLoud', () => { const proc = fakeProc() installFailLoud(NAME, proc) const error = new Error('assembled activation failure') - const audit = assertEntriesActivated({ - get: () => undefined, + const warn = vi.fn() + const audit = auditStartupEntries({ loader: { entries: () => ['broken-a', 'broken-b'].map(name => ({ - options: { name }, + options: { id: name, name }, fiber: { state: 3, inject: {}, @@ -432,13 +432,14 @@ describe('installFailLoud', () => { }, })), }, - } as unknown as Context, NAME) + } as unknown as Context, NAME, warn) await Promise.resolve() await Promise.resolve() proc.handlers[0]!(error) expect(proc.written).toEqual([]) expect(proc.exits).toEqual([]) - await expect(audit).rejects.toThrow('assembled activation failure') + await audit + expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure')) proc.handlers[0]!(error) expect(proc.exits).toEqual([1]) }) @@ -502,37 +503,30 @@ describe('installFailLoud', () => { }) }) -describe('assertEntriesLoaded', () => { - const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context => - ({ loader: { entries: () => entries }, get: () => undefined }) as unknown as Context +describe('auditStartupEntries', () => { + const requiredIds = [ + 'agent-loop', + 'webserver', + 'modules', + 'connection', + 'headless-runner', + 'acp', + 'sdk-jsonrpc-server', + ] - it('passes when every enabled entry has a fiber', () => { - expect(() => { assertEntriesLoaded(ctxWith([ - { fiber: {}, options: { name: 'a' } }, - { disabled: true, options: { name: 'off' } }, - ]), NAME) }).not.toThrow() - }) - - it('throws naming every enabled fiber-less entry', () => { - expect(() => { assertEntriesLoaded(ctxWith([ - { fiber: {}, options: { name: 'ok' } }, - { options: { name: 'broken-a' } }, - { options: { name: 'broken-b' } }, - ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`) - }) -}) - -describe('assertEntriesActivated', () => { - interface FakeFiber { - state: number - inject: Record - ctx: { get(name: string): unknown } - await(): Promise + interface FakeEntry { + fiber?: { + state: number + inject: Record + ctx: { get(name: string): unknown } + await(): Promise + } + disabled?: boolean + options: { id: string; name: string } } - const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({ - loader: { entries: () => entries }, - get: () => undefined, + const ctxWith = (entries: FakeEntry[]): Context => ({ + loader: { entries: () => entries.values() }, }) as unknown as Context const fiber = ( @@ -540,77 +534,163 @@ describe('assertEntriesActivated', () => { error?: unknown, inject: Record = {}, services: string[] = [], - ): FakeFiber => ({ + ): NonNullable => ({ state, inject, ctx: { get: name => services.includes(name) ? {} : undefined }, await: error === undefined ? async () => undefined : async () => { throw error }, }) - it('passes active entries and ignores disabled entries', async () => { - let awaitCalls = 0 - const active = fiber(2) - active.await = async () => { - awaitCalls++ - return undefined + it('ignores active, disabled, and absent required entries', async () => { + const warn = vi.fn() + await expect(auditStartupEntries(ctxWith([]), NAME, warn)).resolves.toBeUndefined() + for (const disabled of [false, true]) { + await expect(auditStartupEntries(ctxWith(requiredIds.map(id => ({ + fiber: disabled ? fiber(3, new Error('disabled failure')) : fiber(2), + disabled, + options: { id, name: './required.mjs' }, + }))), NAME, warn)).resolves.toBeUndefined() } - const disabled = fiber(3, new Error('disabled failure')) - disabled.await = async () => { - awaitCalls++ - throw new Error('disabled failure') - } - await expect(assertEntriesActivated(ctxWith([ - { fiber: active, options: { name: 'active' } }, - { fiber: disabled, disabled: true, options: { name: 'disabled' } }, - ]), NAME)).resolves.toBeUndefined() - expect(awaitCalls).toBe(0) + expect(warn).not.toHaveBeenCalled() }) - it('reports the plugin name and original activation stack instead of fiber state 3', async () => { - const original = new Error('actual plugin failure') - await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(3, original), options: { name: 'broken-plugin' } }, - ]), NAME)).rejects.toThrow(`${NAME}: 1 entry did not activate\nbroken-plugin: ${original.stack!}`) + it('warns once for optional import, apply, and dependency failures', async () => { + const warn = vi.fn() + const original = new Error('todo apply failure') + await auditStartupEntries(ctxWith([ + { options: { id: 'missing-tool', name: './missing.mjs' } }, + { fiber: fiber(3, original), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } }, + { + fiber: fiber(0, undefined, { ready: {}, missing: {} }, ['ready']), + options: { id: 'waiting-tool', name: './waiting.mjs' }, + }, + ]), NAME, warn) + expect(warn).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith([ + `${NAME}: warning: 3 entries did not activate`, + 'missing-tool (./missing.mjs): failed to import', + `tool-todo (@deepseek-ai/dsh-tool-todo): ${original.stack!}`, + 'waiting-tool (./waiting.mjs): pending (waiting for service: missing)', + '', + ].join('\n')) }) - it('formats stackless and non-Error activation failures', async () => { + it.each([ + { id: 'tool-todo', required: false }, + { id: 'webserver', required: true }, + ])('reports a throwing disabled expression on $id (required: $required)', async ({ id, required }) => { + const error = new Error('disabled evaluation failed') + const warn = vi.fn() + const result = auditStartupEntries(ctxWith([{ + options: { id, name: './plugin.mjs' }, + get disabled(): boolean { throw error }, + }]), NAME, warn) + const detail = `${id} (./plugin.mjs): disabled expression failed: ${error.stack!}` + if (required) { + await expect(result).rejects.toThrow(`required startup failure: 1 entry did not activate\n${detail}`) + expect(warn).not.toHaveBeenCalled() + } else { + await expect(result).resolves.toBeUndefined() + expect(warn).toHaveBeenCalledExactlyOnceWith(`${NAME}: warning: 1 entry did not activate\n${detail}\n`) + } + }) + + it('preserves nested activation causes and aggregate member failures', async () => { + const warn = vi.fn() + const original = new Error('tool discovery failed') + const aggregate = new AggregateError([original, 'transport closed'], 'connection failed', { + cause: new Error('server rejected discovery'), + }) + const wrapper = new Error('plugin activation failed', { cause: aggregate }) + await auditStartupEntries(ctxWith([ + { fiber: fiber(3, wrapper), options: { id: 'wrapped-plugin', name: './wrapped.mjs' } }, + ]), NAME, warn) + expect(warn).toHaveBeenCalledWith([ + `${NAME}: warning: 1 entry did not activate`, + `wrapped-plugin (./wrapped.mjs): ${wrapper.stack!}`, + aggregate.stack!, + (aggregate.cause as Error).stack!, + original.stack!, + 'transport closed', + '', + ].join('\n')) + }) + + it('describes nested, stackless, non-error, pending, and unexpected failures', async () => { + const warn = vi.fn() + const circular = new Error('circular failure') + ;(circular as { cause?: unknown }).cause = circular const stackless = new Error('stackless failure') delete (stackless as { stack?: string }).stack - await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(3, stackless), options: { name: 'stackless' } }, - { fiber: fiber(3, 'plain failure'), options: { name: 'plain' } }, - ]), NAME)).rejects.toThrow(`${NAME}: 2 entries did not activate\nstackless: stackless failure\nplain: plain failure`) + const deepestWithStack = new Error('deep failure with stack') + const deepestWithoutStack = new Error('deep failure without stack') + delete (deepestWithoutStack as { stack?: string }).stack + const wrappedStack = new Error('wrapped stack', { cause: deepestWithStack }) + const wrappedStackless = new Error('wrapped stackless', { cause: deepestWithoutStack }) + const wrappedValue = new Error('wrapped value', { cause: 'plain cause' }) + + await auditStartupEntries(ctxWith([ + { fiber: fiber(3, circular), options: { id: 'circular', name: './circular.mjs' } }, + { fiber: fiber(3, stackless), options: { id: 'stackless', name: './stackless.mjs' } }, + { + fiber: fiber(3, wrappedStack), + options: { id: 'deep-stack', name: './deep-stack.mjs' }, + }, + { + fiber: fiber(3, wrappedStackless), + options: { id: 'deep-stackless', name: './deep-stackless.mjs' }, + }, + { + fiber: fiber(3, wrappedValue), + options: { id: 'plain-cause', name: './plain-cause.mjs' }, + }, + { fiber: fiber(3, 42), options: { id: 'number-error', name: './number-error.mjs' } }, + { + fiber: fiber(0, undefined, { first: {}, second: {} }), + options: { id: 'multiple-dependencies', name: './multiple-dependencies.mjs' }, + }, + { + fiber: fiber(0), + options: { id: 'unknown-dependency', name: './unknown-dependency.mjs' }, + }, + { fiber: fiber(1), options: { id: 'unexpected-state', name: './unexpected-state.mjs' } }, + ]), NAME, warn) + + expect(warn).toHaveBeenCalledOnce() + const diagnostic = String(warn.mock.calls[0]![0]) + expect(diagnostic).toContain(`${NAME}: warning: 9 entries did not activate`) + expect(diagnostic).toContain(`circular (./circular.mjs): ${circular.stack!}`) + expect(diagnostic).toContain('stackless (./stackless.mjs): stackless failure') + expect(diagnostic).toContain(`deep-stack (./deep-stack.mjs): ${wrappedStack.stack!}\n${deepestWithStack.stack!}`) + expect(diagnostic).toContain(`deep-stackless (./deep-stackless.mjs): ${wrappedStackless.stack!}\ndeep failure without stack`) + expect(diagnostic).toContain(`plain-cause (./plain-cause.mjs): ${wrappedValue.stack!}\nplain cause`) + expect(diagnostic).toContain('number-error (./number-error.mjs): 42') + expect(diagnostic).toContain('multiple-dependencies (./multiple-dependencies.mjs): pending (waiting for services: first, second)') + expect(diagnostic).toContain('unknown-dependency (./unknown-dependency.mjs): pending (waiting for services: unknown)') + expect(diagnostic).toContain('unexpected-state (./unexpected-state.mjs): fiber state 1') }) - it('reports unresolved services for pending entries', async () => { - let awaitCalls = 0 - const expected = [ - `${NAME}: 3 entries did not activate`, - 'waiting: pending (waiting for services: missingA, missingB)', - 'single-wait: pending (waiting for service: missing)', - 'unknown-wait: pending (waiting for services: unknown)', - ].join('\n') - const waiting = fiber(0, undefined, { ready: {}, missingA: {}, missingB: {} }, ['ready']) - const singleWait = fiber(0, undefined, { missing: {} }) - const unknownWait = fiber(0) - for (const item of [waiting, singleWait, unknownWait]) { - item.await = async () => { - awaitCalls++ - return undefined - } - } - await expect(assertEntriesActivated(ctxWith([ - { fiber: waiting, options: { name: 'waiting' } }, - { fiber: singleWait, options: { name: 'single-wait' } }, - { fiber: unknownWait, options: { name: 'unknown-wait' } }, - ]), NAME)).rejects.toThrow(expected) - expect(awaitCalls).toBe(0) + it.each(requiredIds)('rejects required %s failures after warning about optional failures', async (id) => { + const warn = vi.fn() + const requiredError = new Error('address already in use') + const optionalError = new Error('todo unavailable') + await expect(auditStartupEntries(ctxWith([ + { fiber: fiber(3, requiredError), options: { id, name: './required.mjs' } }, + { fiber: fiber(3, optionalError), options: { id: 'tool-todo', name: '@deepseek-ai/dsh-tool-todo' } }, + ]), NAME, warn)).rejects.toThrow([ + 'required startup failure: 1 entry did not activate', + `${id} (./required.mjs): ${requiredError.stack!}`, + ].join('\n')) + expect(warn).toHaveBeenCalledWith(`${NAME}: warning: 1 entry did not activate\ntool-todo (@deepseek-ai/dsh-tool-todo): ${optionalError.stack!}\n`) }) - it('retains the numeric diagnostic for a settled unexpected state', async () => { - await expect(assertEntriesActivated(ctxWith([ - { fiber: fiber(4), options: { name: 'disposed' } }, - ]), NAME)).rejects.toThrow('disposed: fiber state 4') + it('rejects a required entry pending on an injected service', async () => { + await expect(auditStartupEntries(ctxWith([{ + fiber: fiber(0, undefined, { headlessStartup: {} }), + options: { id: 'headless-runner', name: '@deepseek-ai/dsh-headless' }, + }]), NAME, vi.fn())).rejects.toThrow( + 'headless-runner (@deepseek-ai/dsh-headless): pending (waiting for service: headlessStartup)', + ) }) }) @@ -731,22 +811,6 @@ describe('boot', () => { } }) - it('disposes partial host setup and labels non-Error preparation failures', async () => { - const dir = tmp() - const failure = 42 - let disposed = false - const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { - ctx.effect(() => () => { disposed = true }) - throw failure - }) - - await expect(task).rejects.toMatchObject({ - message: `${NAME}: host preparation failed: ${failure}`, - cause: failure, - }) - expect(disposed).toBe(true) - }) - it('exposes dshHomePath to Loader config expressions', async () => { const dir = tmp() const dshHome = join(dir, 'home') @@ -804,48 +868,140 @@ describe('boot', () => { expect(ctx.get('loader')).toBeUndefined() }) - it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { + it('returns when disposal completes before root entry creation returns', async () => { const dir = tmp() - writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow( - `${NAME}: plugin tree failed to load: failed to apply loader entry`, - ) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml'), [], (ctx) => { + const create = ctx.loader.create.bind(ctx.loader) + vi.spyOn(ctx.loader, 'create').mockImplementation(async (...args) => { + const id = await create(...args) + await ctx.fiber.dispose() + return id + }) + }) + expect(ctx.get('loader')).toBeUndefined() }) - it('labels a deferred config failure with its row and leaves the source file unchanged', async () => { + it('keeps successful entries and warns about optional import, config, disabled, sync apply, async apply, and dependency failures', async () => { const dir = tmp() const configPath = join(dir, 'cordis.yml') const config = [ + '- id: good', + ' name: ./good.mjs', + '- id: import-failure', + ' name: ./missing.mjs', '- id: invalid-config', ' name: ./noop.mjs', ' config:', ' value: !!js "JSON.parse(\'invalid\')"', + '- id: disabled-failure', + ' name: ./noop.mjs', + ' disabled: !!js "JSON.parse(\'invalid\')"', + '- id: sync-failure', + ' name: ./sync-failure.mjs', + '- id: async-failure', + ' name: ./async-failure.mjs', + '- id: waiting', + ' name: ./waiting.mjs', '', ].join('\n') + writeFileSync(join(dir, 'good.mjs'), 'export function apply(ctx) { ctx.provide("goodStarted", true) }\n') writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'sync-failure.mjs'), 'export function apply() { throw new Error("sync apply failure") }\n') + writeFileSync(join(dir, 'async-failure.mjs'), 'export async function apply() { throw new Error("async apply failure") }\n') + writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') writeFileSync(configPath, config) - await expect(boot(NAME, configPath)).rejects.toThrow( - 'failed to apply loader entry invalid-config (./noop.mjs)', - ) - expect(readFileSync(configPath, 'utf8')).toBe(config) + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + let ctx: Context | undefined + try { + ctx = await boot(NAME, configPath) + expect(ctx.get('goodStarted')).toBe(true) + const entries = [...ctx.loader.entries()] + expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2) + expect(entries.find(entry => entry.options.id === 'import-failure')?.fiber).toBeUndefined() + expect(entries.find(entry => entry.options.id === 'disabled-failure')?.fiber).toBeUndefined() + for (const id of ['invalid-config', 'sync-failure', 'async-failure']) { + expect(entries.find(entry => entry.options.id === id)?.fiber?.state).toBe(3) + } + expect(entries.find(entry => entry.options.id === 'waiting')?.fiber?.state).toBe(0) + const warning = write.mock.calls.map(call => String(call[0])).join('') + expect(warning).toContain(`${NAME}: warning: 6 entries did not activate`) + expect(warning).toContain('import-failure (./missing.mjs): failed to import') + expect(warning).toContain('disabled-failure (./noop.mjs): disabled expression failed: SyntaxError') + expect(warning).toContain('SyntaxError: Unexpected token') + expect(warning).toContain('sync apply failure') + expect(warning).toContain('async apply failure') + expect(warning).toContain('waiting for service: neverProvided') + expect(readFileSync(configPath, 'utf8')).toBe(config) + } finally { + write.mockRestore() + await ctx?.fiber.dispose() + } }) - it('appends the deepest cause with its original stack to the load failure', async () => { + it.each([ + ['missing', undefined, 'config file not found'], + ['malformed', 'invalid: [unclosed\n', 'unexpected end'], + ['non-array', 'entries: []\n', 'top-level array'], + ])('rejects a %s root configuration', async (_kind, content, message) => { const dir = tmp() - writeFileSync(join(dir, 'failing.mjs'), [ - 'export function apply() {', - " const failure = new Error('pinned activation failure')", - " failure.stack = 'Error: pinned activation failure\\n at failing-fixture'", - ' throw failure', + const configPath = join(dir, 'cordis.yml') + if (content !== undefined) writeFileSync(configPath, content) + let ctx: Context | undefined + try { + await expect(boot(NAME, configPath).then((value) => { ctx = value })).rejects.toThrow(message) + } finally { + await ctx?.fiber.dispose() + } + }) + + it.each([ + ['import', undefined, '', 'failed to import'], + ['config schema', 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate() { return { issues: [{ message: "schema failure" }] } } } }\nexport function apply() {}\n', '', 'schema failure'], + ['config expression', 'export function apply() {}\n', ' config: { value: !!js "JSON.parse(\'invalid\')" }\n', 'SyntaxError'], + ['disabled expression', 'export function apply() {}\n', ' disabled: !!js "JSON.parse(\'invalid\')"\n', 'required startup failure: 1 entry did not activate\nwebserver (./required.mjs): disabled expression failed: SyntaxError'], + ['sync apply', 'export function apply() { throw new Error("sync failure") }\n', '', 'sync failure'], + ['async apply', 'export async function apply() { await Promise.resolve(); throw new Error("async failure") }\n', '', 'async failure'], + ['missing dependency', 'export const inject = ["missingRequiredService"]\nexport function apply() {}\n', '', 'missingRequiredService'], + ])('disposes startup after a required %s failure', async (_kind, source, config, message) => { + const dir = tmp() + if (source !== undefined) writeFileSync(join(dir, 'required.mjs'), source) + writeFileSync(join(dir, 'cordis.yml'), `- id: webserver\n name: ./required.mjs\n${config}`) + let disposed = false + await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => { + ctx.effect(() => () => { disposed = true }) + })).rejects.toThrow(message) + expect(disposed).toBe(true) + }) + + it('disposes successful entries and rejects when a required entry fails', async () => { + const dir = tmp() + let disposed = false + writeFileSync(join(dir, 'good.mjs'), [ + 'export function apply(ctx) {', + ' globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = false', + ' ctx.effect(() => () => { globalThis.__DSH_REQUIRED_TEST_DISPOSED__ = true })', '}', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n') + writeFileSync(join(dir, 'required-failure.mjs'), 'export function apply() { throw new Error("required apply failure") }\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: good', + ' name: ./good.mjs', + '- id: webserver', + ' name: ./required-failure.mjs', + '', + ].join('\n')) + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([ - String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`, - String.raw`Error: pinned activation failure\n {4}at failing-fixture$`, - ].join(''))) + 'plugin tree failed to load: required startup failure: 1 entry did not activate', + String.raw`webserver \(\.\/required-failure\.mjs\):`, + 'required apply failure', + ].join(String.raw`[\s\S]*`))) + disposed = (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ ?? false + delete (globalThis as { __DSH_REQUIRED_TEST_DISPOSED__?: boolean }).__DSH_REQUIRED_TEST_DISPOSED__ + expect(disposed).toBe(true) }) it('falls back to the deepest cause message when its stack was erased', async () => { @@ -859,6 +1015,31 @@ describe('boot', () => { ) }) + it('reports a non-Error rejection from host preparation', async () => { + await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], () => { + throw 'host refused' + })).rejects.toThrow(`${NAME}: host preparation failed: host refused`) + }) + + it.each([false, true])('rejects and disposes when an error cause is cyclic (indirect: %s)', async (indirect) => { + const failure = new Error('cyclic setup failure') + const next = indirect ? new Error('nested failure', { cause: failure }) : failure + let reads = 0 + Object.defineProperty(failure, 'cause', { + get() { + // Bound a regressed synchronous traversal so it cannot hang the test worker. + if (++reads > 10) throw new Error('cause traversal did not terminate') + return next + }, + }) + const dispose = vi.fn() + await expect(boot(NAME, join(tmp(), 'cordis.yml'), [], (ctx) => { + ctx.effect(() => dispose) + throw failure + })).rejects.toThrow(`${NAME}: host preparation failed: cyclic setup failure`) + expect(dispose).toHaveBeenCalledOnce() + }) + it('expands a stackless aggregate at the deepest activation cause', async () => { const dir = tmp() const aggregate = new AggregateError([ @@ -881,15 +1062,6 @@ describe('boot', () => { } }) - it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { - const dir = tmp() - writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') - writeFileSync(join(dir, 'cordis.yml'), '- id: waiting\n name: ./waiting.mjs\n') - await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow([ - `${NAME}: 1 entry did not activate`, - './waiting.mjs: pending (waiting for service: neverProvided)', - ].join('\n')) - }) }) describe('addHarnessSourceSection', () => { diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 386b537ce4..4d8dae27e4 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -1,10 +1,6 @@ -/** - * Transactional config replacement through the booted Include and Loader tree. - * HMR contains rejected refreshes; direct callers receive the error after the - * previous generation has been retained or restored. - */ +/** File reload and overlay behavior through the booted Include tree. */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' @@ -43,41 +39,40 @@ function entryConfig(ctx: Context, id: string): unknown { return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config } -function entryById(ctx: Context, id: string) { - const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id) - if (!entry) throw new Error(`missing loader entry ${id}`) - return entry -} - -function plugin(name: string, body = ''): string { - return `export default function ${name}(_ctx, config = {}) { ${body} }\n` -} - -async function expectUpdateFailure(task: Promise, stage: string): Promise { - try { - await task - } catch (error) { - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain(`failed to ${stage} loader entry`) - return - } - throw new Error(`expected loader update to fail during ${stage}`) -} - describe('include refresh with an invalid file', () => { - it('rejects while keeping the last good tree, then applies the next valid edit', async () => { + it('writes and activates initial entries when an included file is missing', async () => { + const { ctx, dir } = await bootTree([ + '- id: initialized', + " name: 'cordis:include'", + ' config:', + ' path: ./created.yml', + ' initial:', + ' - id: noop', + ' name: ./noop.mjs', + ' config: { value: initial }', + '', + ].join('\n')) + try { + expect(entryConfig(ctx, 'noop')).toEqual({ value: 'initial' }) + expect(readFileSync(join(dir, 'created.yml'), 'utf8')).toContain('id: noop') + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => { const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n') try { expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n') - await expect(include.refresh()).rejects.toThrow('failed to parse config file') + await expect(include.refresh()).resolves.toBeUndefined() expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) // An empty file parses to `undefined` without a YAML error; it must be // treated exactly like a parse failure, not crash the entry walk. writeFileSync(join(dir, 'cordis.yml'), '') - await expect(include.refresh()).rejects.toThrow('failed to validate config file') + await expect(include.refresh()).resolves.toBeUndefined() expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 }) writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n') @@ -90,201 +85,6 @@ describe('include refresh with an invalid file', () => { }) }) -describe('loader entry replacement', () => { - it('imports a changed name before replacing the running plugin', async () => { - const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { - 'old.mjs': plugin('oldPlugin'), - 'new.mjs': plugin('newPlugin'), - }) - try { - const entry = entryById(ctx, 'target') - await entry.update({ name: './new.mjs' }) - expect(entry.options.name).toBe('./new.mjs') - expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) - expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin') - expect(entry.options.disabled).toBeUndefined() - await entry.fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('retains the running plugin when the replacement cannot be imported', async () => { - const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { - 'old.mjs': plugin('oldPlugin'), - }) - try { - const entry = entryById(ctx, 'target') - const fiber = entry.fiber - await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import') - expect(entry.options.name).toBe('./old.mjs') - expect(entry.fiber === fiber).toBe(true) - await fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('restores the previous plugin after replacement application fails', async () => { - const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', { - 'old.mjs': plugin('oldPlugin'), - 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), - }) - try { - const entry = entryById(ctx, 'target') - const previous = entry.fiber - await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply') - expect(entry.options.name).toBe('./old.mjs') - expect(entry.fiber === previous).toBe(false) - expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin') - expect(entry.options.disabled).toBeUndefined() - await entry.fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('restores the previous config when an in-place restart fails', async () => { - const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { - 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), - }) - try { - const entry = entryById(ctx, 'target') - const fiber = entry.fiber - await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply') - expect(entry.options.config).toEqual({ fail: false }) - expect(entry.fiber === fiber).toBe(true) - await fiber?.await() - } finally { - await ctx.fiber.dispose() - } - }) - - it('does not persist a failed direct fiber update', async () => { - const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', { - 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), - }) - try { - const entry = entryById(ctx, 'target') - const fiber = entry.fiber - if (!fiber) throw new Error('target entry has no fiber') - await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed') - expect(entry.options.config).toEqual({ fail: false }) - expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options) - } finally { - await ctx.fiber.dispose() - } - }) -}) - -describe('loader tree replacement', () => { - it('rolls back earlier updates and additions when a later entry fails', async () => { - const { ctx, dir, include } = await bootTree([ - '- id: existing', - ' name: ./configurable.mjs', - ' config:', - ' value: old', - '', - ].join('\n'), { - 'configurable.mjs': plugin('configurablePlugin'), - 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'), - }) - try { - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: existing', - ' name: ./configurable.mjs', - ' config:', - ' value: candidate', - '- id: added', - ' name: ./noop.mjs', - '- id: bad', - ' name: ./bad.mjs', - '', - ].join('\n')) - await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad') - expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false) - - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: existing', - ' name: ./configurable.mjs', - ' config:', - ' value: committed', - '- id: added', - ' name: ./noop.mjs', - '', - ].join('\n')) - await include.refresh() - expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' }) - expect(entryById(ctx, 'added').fiber).toBeDefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => { - // No manual builtin registration: `boot()` supplies `cordis:group` beside - // `cordis:include`, which is what lets a composition give one `isolate` - // realm to a provider and its consumers together. - const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n') - try { - const config = (disabled: boolean) => [ - '- id: parent', - ' name: cordis:group', - ' group: true', - ` disabled: ${disabled}`, - ' config:', - ' - id: child', - ' name: ./noop.mjs', - '', - ].join('\n') - - writeFileSync(join(dir, 'cordis.yml'), config(false)) - await include.refresh() - expect(entryById(ctx, 'child').fiber).toBeDefined() - - writeFileSync(join(dir, 'cordis.yml'), config(true)) - await include.refresh() - expect(entryById(ctx, 'child').fiber).toBeUndefined() - - writeFileSync(join(dir, 'cordis.yml'), config(false)) - await include.refresh() - expect(entryById(ctx, 'child').fiber).toBeDefined() - } finally { - await ctx.fiber.dispose() - } - }) - - it('restores a programmatic entry move when its update fails', async () => { - const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', { - 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'), - }) - try { - const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) - const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } }) - const target = entryById(ctx, targetId) - const source = target.parent - const sourceIndex = source.data.indexOf(target.options) - const destination = entryById(ctx, groupId).subgroup - if (!destination) throw new Error('created loader group has no subgroup') - - await expectUpdateFailure( - ctx.loader.update(targetId, { config: { fail: true } }, groupId), - 'apply', - ) - - expect(target.parent).toBe(source) - expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx) - expect(source.data.indexOf(target.options)).toBe(sourceIndex) - expect(destination.data).not.toContain(target.options) - expect(target.options.config).toEqual({ fail: false }) - } finally { - await ctx.fiber.dispose() - } - }) -}) - describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) @@ -335,9 +135,9 @@ describe('include refresh with overlay patches', () => { await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' }) - // Omitting the patch list must remove the overlay rather than reuse the - // Include's previous config through a default parameter. - await entry.update({ config: { path: './base.yml' } }) + // Removing every patch must revert to the file's own values: patching + // may not bake earlier patch results into the cached parse. + await entry.update({ config: { path: './base.yml', patches: [] } }) await ctx.loader.await() expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' }) } finally { @@ -395,6 +195,71 @@ describe('include patches layered over one base', () => { }) }) +describe('best-effort config failure recovery', () => { + it.each([ + ['import', undefined, undefined], + ['sync apply', 'export function apply(_ctx, config) { if (config.fail) throw new Error("reload sync failure") }\n', 3], + ['async apply', 'export async function apply(_ctx, config) { await Promise.resolve(); if (config.fail) throw new Error("reload async failure") }\n', 3], + ['dependency', 'export const inject = ["reloadMissing"]\nexport function apply() {}\n', 0], + ] as const)('keeps siblings after a required-id %s failure during HMR', async (_kind, source, state) => { + const base = '- id: good\n name: ./noop.mjs\n' + const { ctx, dir, include } = await bootTree(base, { + ...source === undefined ? {} : { 'failure.mjs': source }, + 'provider.mjs': 'export function apply(ctx) { ctx.provide("reloadMissing", true) }\n', + }) + try { + const good = [...ctx.loader.entries()].find(entry => entry.options.id === 'good')!.fiber + writeFileSync(join(dir, 'cordis.yml'), base + '- id: webserver\n name: ./failure.mjs\n config: { fail: true }\n') + await include.refresh() + await ctx.loader.await() + const failed = [...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')! + expect(failed.fiber?.state).toBe(state) + expect(good?.state).toBe(2) + expect(ctx.fiber.state).toBe(2) + + const recovery = `- id: webserver\n name: ./${source === undefined ? 'noop' : 'failure'}.mjs\n config: { fail: false }\n` + const provider = state === 0 ? '- id: provider\n name: ./provider.mjs\n' : '' + writeFileSync(join(dir, 'cordis.yml'), base + recovery + provider) + await include.refresh() + await ctx.loader.await() + expect([...ctx.loader.entries()].find(entry => entry.options.id === 'webserver')?.fiber?.state).toBe(2) + expect([...ctx.loader.entries()].find(entry => entry.options.id === 'good')?.fiber).toBe(good) + } finally { + await ctx.fiber.dispose() + } + }) + + it('keeps the previous fiber config after schema rejection and retries a valid edit', async () => { + const config = (value: number | string): string => `- id: webserver\n name: ./schema.mjs\n config: { value: ${JSON.stringify(value)} }\n` + const { ctx, dir, include } = await bootTree(config(1), { + 'schema.mjs': [ + 'export const Config = { "~standard": { version: 1, vendor: "app-boot-test", validate(config) {', + ' return typeof config.value === "number" ? { value: config } : { issues: [{ message: "expected number" }] }', + '} } }', + 'export function apply(ctx, config) { ctx.provide("validatedValue", config.value) }', + '', + ].join('\n'), + }) + try { + writeFileSync(join(dir, 'cordis.yml'), config('invalid')) + await include.refresh() + await ctx.loader.await() + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === 'webserver')! + expect(entry.options.config).toEqual({ value: 'invalid' }) + expect(entry.fiber?.config).toEqual({ value: 1 }) + expect(ctx.get('validatedValue')).toBe(1) + + writeFileSync(join(dir, 'cordis.yml'), config(2)) + await include.refresh() + await ctx.loader.await() + expect(entry.fiber?.config).toEqual({ value: 2 }) + expect(ctx.get('validatedValue')).toBe(2) + } finally { + await ctx.fiber.dispose() + } + }) +}) + describe('shipped builtins', () => { it('lets a booted composition share one isolate realm across a group of rows', async () => { // The reason `boot()` registers `cordis:group`: a composition — notably an diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts deleted file mode 100644 index 6487ad0695..0000000000 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' -import { realpath } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import Hmr from '@deepseek-ai/cordis-plugin-hmr' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Timer from '@deepseek-ai/cordis-plugin-timer' -import { afterEach, describe, expect, it, vi } from 'vitest' - -/** Every per-test tree root, removed once the booted watcher has been disposed. */ -const hmrRoots: string[] = [] - -async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(dir).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(Timer) - await ctx.plugin(Hmr, { - root, - ignored: [], - debounce: 0, - ...usePolling === undefined ? {} : { usePolling }, - }) - return ctx -} - -async function eventually(test: () => boolean, message: string): Promise { - const deadline = Date.now() + 10_000 - while (!test()) { - if (Date.now() >= deadline) throw new Error(message) - await new Promise(resolve => setTimeout(resolve, 10)) - } -} - -describe('HMR exact config paths', () => { - afterEach(() => { - for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) - }) - - it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { - const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) - const alias = `${target}-alias` - const aliasFilename = join(alias, 'module.ts') - symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') - writeFileSync(aliasFilename, 'export const generation = 0\n') - // This acceptance owns alias-to-cache identity. Other cases below exercise - // native events; polling keeps Windows fs.watch queue pressure out of it. - const ctx = await bootHmr(alias, ['.'], true) - const filename = join(await realpath(target), 'module.ts') - const expected = pathToFileURL(filename).href - const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) - const observed: string[] = [] - ctx.on('hmr/change', (url) => { observed.push(url) }) - try { - const deadline = Date.now() + 20_000 - for (let generation = 1; !observed.includes(expected); generation += 1) { - if (Date.now() >= deadline) { - throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`) - } - // The watch base, not the writer spelling, is the alias under test. - // Grow the file on every write: polling must not depend on timestamp - // precision when several generations land inside one filesystem tick. - writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`) - // Leave Chokidar's atomic-write window idle so one coalesced change can publish. - await new Promise(resolve => setTimeout(resolve, 250)) - } - expect(cacheHas).toHaveBeenCalledWith(expected) - } finally { - await ctx.fiber.dispose() - unlinkSync(alias) - rmSync(target, { recursive: true, force: true }) - } - }) - - it('collapses filesystem aliases before registering an exact watch', async () => { - const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) - const alias = `${target}-alias` - symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') - const ctx = await bootHmr(alias) - try { - await ctx.hmr.registerConfig('plugins.yml', () => {}) - await expect(ctx.hmr.registerConfig(join(await realpath(target), 'plugins.yml'), () => {})) - .rejects.toThrow('config path already registered') - } finally { - await ctx.fiber.dispose() - unlinkSync(alias) - rmSync(target, { recursive: true, force: true }) - } - }) - - it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(dir) - const filename = join(dir, 'plugins.yml') - const ctx = await bootHmr(dir) - const observed: string[] = [] - try { - await ctx.hmr.registerConfig(filename, () => { - try { - observed.push(readFileSync(filename, 'utf8')) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - observed.push('missing') - } - }) - - writeFileSync(filename, 'one', { flag: 'wx' }) - await eventually(() => observed.includes('one'), 'HMR did not observe config creation') - writeFileSync(filename, 'two') - await eventually(() => observed.includes('two'), 'HMR did not observe config change') - unlinkSync(filename) - await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') - } finally { - await ctx.fiber.dispose() - } - }) - - it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { - const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(root) - const dir = join(root, 'later') - const filename = join(dir, 'plugins.yml') - const ctx = await bootHmr(root) - const observed: string[] = [] - try { - await ctx.hmr.registerConfig(filename, () => { - observed.push(readFileSync(filename, 'utf8')) - }) - mkdirSync(dir) - writeFileSync(filename, 'created') - await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') - } finally { - await ctx.fiber.dispose() - } - }) - - it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(dir) - const filename = join(dir, 'plugins.yml') - writeFileSync(filename, 'one') - const ctx = await bootHmr(dir) - const started = Promise.withResolvers() - const release = Promise.withResolvers() - const observed: string[] = [] - let active = 0 - let maxActive = 0 - try { - const dispose = await ctx.hmr.registerConfig(filename, async () => { - active += 1 - maxActive = Math.max(maxActive, active) - observed.push(readFileSync(filename, 'utf8')) - if (observed.length === 1) { - started.resolve(undefined) - await release.promise - } - active -= 1 - }) - await started.promise - writeFileSync(filename, 'two') - // Chokidar coalesces atomic writes for 100 ms by default. Wait beyond - // that window so this edit is queued before registration disposal. - await new Promise(resolve => setTimeout(resolve, 250)) - - let disposed = false - const disposal = dispose().then(() => { disposed = true }) - await Promise.resolve() - expect(disposed).toBe(false) - release.resolve(undefined) - await disposal - expect(maxActive).toBe(1) - expect(observed).toEqual(['one', 'two']) - } finally { - release.resolve(undefined) - await ctx.fiber.dispose() - } - }) - - it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) - hmrRoots.push(dir) - const filename = join(dir, 'plugins.yml') - const ctx = await bootHmr(dir) - const failure = Promise.withResolvers<{ filename: string; error: Error }>() - let failureCount = 0 - try { - ctx.on('hmr/config-update-failed', () => { - throw new Error('observer failed') - }) - ctx.on('hmr/config-update-failed', (failedFilename, error) => { - failureCount += 1 - failure.resolve({ filename: failedFilename, error }) - }) - await ctx.hmr.registerConfig(filename, () => { throw 42 }) - writeFileSync(filename, 'invalid') - - const observed = await failure.promise - expect(observed.filename).toBe(filename) - expect(observed.error).toBeInstanceOf(Error) - expect(observed.error.message).toBe('42') - - // Let Chokidar's atomic-write window close before requiring a distinct - // second notification from the same path. - await new Promise(resolve => setTimeout(resolve, 250)) - writeFileSync(filename, 'invalid again') - await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected') - } finally { - await ctx.fiber.dispose() - } - }) -}) diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 5e4a20e3d2..49be7319ad 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -1,7 +1,7 @@ /** * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over - * a real Loader tree, kept live through transactional HMR. + * a real Loader tree with live file watching. */ import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' @@ -178,15 +178,15 @@ describe('loadOptionalPatches', () => { }) }) -function writeTree(dir: string): string { +function writeTree(dir: string, id = 'noop', asyncApply = false): string { writeFileSync(join(dir, 'noop.mjs'), [ 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', + `export ${asyncApply ? 'async ' : ''}function apply(_ctx, config = {}) {`, ' if (config.fail) throw new Error("candidate config failed")', '}', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + writeFileSync(join(dir, 'cordis.yml'), `- id: ${id}\n name: ./noop.mjs\n config:\n value: base\n`) return join(dir, 'cordis.yml') } @@ -270,7 +270,9 @@ describe('Loader config interpolation', () => { await provider?.update({ disabled: true }) await provider?.update({ config: { fail: true } }) await provider?.update({ disabled: false }) - await expect(ctx.loader.await()).rejects.toThrow('rejected provider') + await ctx.loader.await() + const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') + await expect(reader?.fiber?.await()).rejects.toThrow('rejected provider') expect(ctx.get('readerResult')).toBeUndefined() await provider?.update({ disabled: true }) @@ -342,7 +344,8 @@ describe('Loader entry disabled interpolation', () => { const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean await entry?.update({ disabled: disabledTrue }) expect(entry?.disabled).toBe(true) - expect(entry?.fiber).toBeUndefined() + await ctx.loader.await() + expect(entry?.fiber?.uid).toBeNull() await entry?.update({ disabled: disabledFalse }) expect(entry?.disabled).toBe(false) expect(entry?.fiber).toBeDefined() @@ -403,17 +406,21 @@ describe('boot with user patches', () => { } }) - it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { + it.each([ + { id: 'noop', asyncApply: false }, + { id: 'webserver', asyncApply: false }, + { id: 'webserver', asyncApply: true }, + ])('keeps HMR best effort and recovers ($id, async apply: $asyncApply)', { timeout: 20_000 }, async ({ id, asyncApply }) => { const dir = tmp() const userDir = tmp() const filename = join(userDir, PROFILE_PATCH_FILENAME) - const basePatches = [{ id: 'noop', config: { value: 'generated' } }] - const ctx = await boot(NAME, writeTree(dir), basePatches) + const basePatches = [{ id, config: { value: 'generated' } }] + const ctx = await boot(NAME, writeTree(dir, id, asyncApply), basePatches) onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - // Native notifications belong to hmr-config.spec.ts; this case owns the - // real HMR/Include transaction after each delivered filesystem event. + // Native notifications belong to watch-config.spec.ts; this case owns + // Include recomposition after each explicitly delivered filesystem event. const watchers: FSWatcher[] = [] const previousFactory = configWatch.create onTestFinished(() => { configWatch.create = previousFactory }) @@ -423,10 +430,11 @@ describe('boot with user patches', () => { queueMicrotask(() => { watcher.emit('ready') }) return watcher } - const failures: Array<{ filename: string; error: Error }> = [] - ctx.on('hmr/config-update-failed', (failedFilename, error) => { - failures.push({ filename: failedFilename, error }) + const failures: Error[] = [] + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation((value: unknown) => { + if (value instanceof Error) failures.push(value) }) + onTestFinished(() => { warn.mockRestore() }) const dispose = await watchUserPatches(ctx, { binName: NAME, filename, @@ -440,31 +448,40 @@ describe('boot with user patches', () => { expect(watchers).toHaveLength(1) const watcher = watchers[0]! try { - writeFileSync(filename, '- id: noop\n config:\n value: live\n') + writeFileSync(filename, `- id: ${id}\n config:\n value: live\n`) watcher.emit('add', filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied') + await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'live', 'user patch addition was not applied') - writeFileSync(filename, '- id: noop\n config:\n fail: true\n') + writeFileSync(filename, `- id: ${id}\n config:\n fail: true\n`) watcher.emit('change', filename) - await eventually(() => failures.length === 1, 'failed candidate was not broadcast') - expect(failures[0]).toMatchObject({ filename }) - expect(failures[0]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await eventually(() => failures.length === 1, 'failed candidate was not reported') + expect(failures[0]).toBeInstanceOf(Error) + expect(entryConfig(ctx, id)).toMatchObject({ fail: true }) + + writeFileSync(filename, `- id: ${id}\n disabled: !!js "JSON.parse('invalid')"\n`) + watcher.emit('change', filename) + await eventually(() => failures.length === 2, 'disabled expression failure was not reported') + expect(failures[1]?.message).toContain(`${id} (./noop.mjs): disabled expression failed: SyntaxError`) + expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled) + .toEqual({ __jsExpr: "JSON.parse('invalid')" }) writeFileSync(filename, 'invalid: [unclosed\n') watcher.emit('change', filename) - await eventually(() => failures.length === 2, 'parse failure was not broadcast') - expect(failures[1]?.error).toBeInstanceOf(Error) - expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live') + await eventually(() => failures.length === 3, 'parse failure was not reported') + expect(failures[2]).toBeInstanceOf(Error) + expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled) + .toEqual({ __jsExpr: "JSON.parse('invalid')" }) - writeFileSync(filename, '- id: noop\n config:\n value: recovered\n') + writeFileSync(filename, `- id: ${id}\n config:\n value: recovered\n`) watcher.emit('change', filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied') + await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'recovered', 'valid recovery was not applied') + await ctx.loader.await() + expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.fiber?.state).toBe(2) unlinkSync(filename) watcher.emit('unlink', filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch') - expect(failures).toHaveLength(2) + await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch') + expect(failures).toHaveLength(3) // Default re-application: the user layer IS the whole patch list, so a // fresh generation replaces the app-owned layer instead of stacking on it. @@ -472,12 +489,12 @@ describe('boot with user patches', () => { const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename }) expect(watchers).toHaveLength(2) try { - writeFileSync(filename, '- id: noop\n config:\n value: identity\n') + writeFileSync(filename, `- id: ${id}\n config:\n value: identity\n`) watchers[1]!.emit('add', filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied') + await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'identity', 'default-compose user patch was not applied') unlinkSync(filename) watchers[1]!.emit('unlink', filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'base', 'default-compose removal did not empty the patch list') + await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'base', 'default-compose removal did not empty the patch list') } finally { await disposeDefault() } @@ -502,21 +519,24 @@ describe('boot with user patches', () => { }) it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => { - // A surface can dispose the whole tree while registerConfig's effect - // registration is still in flight (the HMR effect then fails with - // INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher - // must not crash the process. The stub makes the race deterministic — the - // live-teardown ordering itself is not stageable. const dir = tmp() const ctx = await boot(NAME, writeTree(dir)) - try { - const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) - ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) - await expect(dispose()).resolves.toBeUndefined() - } finally { - await ctx.fiber.dispose() + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + const release = Promise.withResolvers() + ctx.effect(() => () => release.promise) + let disposal: Promise | undefined + onTestFinished(async () => { release.resolve(undefined); await disposal }) + configWatch.create = (options) => { + const watcher = new FSWatcher(options) + disposal = ctx.fiber.dispose() + queueMicrotask(() => { watcher.emit('ready') }) + return watcher } + const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) + await expect(dispose()).resolves.toBeUndefined() }) it('propagates registration failures other than mid-teardown', async () => { @@ -527,7 +547,7 @@ describe('boot with user patches', () => { await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) const dispose = await watchUserPatches(ctx, { binName: NAME, filename }) - // Same user-layer path registered twice: HMR refuses; not a teardown race. + // Same user-layer path registered twice: the watcher refuses; not a teardown race. await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') await dispose() } finally { diff --git a/packages/boot/app-boot/tests/watch-config.spec.ts b/packages/boot/app-boot/tests/watch-config.spec.ts new file mode 100644 index 0000000000..aeed3dadbf --- /dev/null +++ b/packages/boot/app-boot/tests/watch-config.spec.ts @@ -0,0 +1,286 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { realpath } from 'node:fs/promises' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, parse } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import { watchConfig } from '../src/watch-config.ts' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { FSWatcher, type ChokidarOptions } from 'chokidar' + +const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined })) +vi.mock('node:fs/promises', async (importOriginal) => { + const native = await importOriginal() + return { ...native, stat: vi.fn(native.stat) } +}) +vi.mock('chokidar', async (importOriginal) => { + const native = await importOriginal() + return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) => + configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) } +}) + +/** Every per-test tree root, removed once the booted watcher has been disposed. */ +const hmrRoots: string[] = [] + +async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dir).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(Timer) + await ctx.plugin(Hmr, { + root, + ignored: [], + debounce: 0, + ...usePolling === undefined ? {} : { usePolling }, + }) + return ctx +} + +async function eventually(test: () => boolean, message: string): Promise { + const deadline = Date.now() + 10_000 + while (!test()) { + if (Date.now() >= deadline) throw new Error(message) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +describe('HMR exact config paths', () => { + afterEach(() => { + for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + }) + + it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) + const alias = `${target}-alias` + const aliasFilename = join(alias, 'module.ts') + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + writeFileSync(aliasFilename, 'export const generation = 0\n') + // This acceptance owns alias-to-cache identity. Other cases below exercise + // native events; polling keeps Windows fs.watch queue pressure out of it. + const ctx = await bootHmr(alias, ['.'], true) + const filename = join(await realpath(target), 'module.ts') + const expected = pathToFileURL(filename).href + const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false) + const observed: string[] = [] + ctx.on('hmr/change', (url) => { observed.push(url) }) + try { + const deadline = Date.now() + 20_000 + for (let generation = 1; !observed.includes(expected); generation += 1) { + if (Date.now() >= deadline) { + throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`) + } + // The watch base, not the writer spelling, is the alias under test. + // Grow the file on every write: polling must not depend on timestamp + // precision when several generations land inside one filesystem tick. + writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`) + // Leave Chokidar's atomic-write window idle so one coalesced change can publish. + await new Promise(resolve => setTimeout(resolve, 250)) + } + expect(cacheHas).toHaveBeenCalledWith(expected) + } finally { + await ctx.fiber.dispose() + unlinkSync(alias) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('collapses filesystem aliases before registering an exact watch', async () => { + const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-')) + const alias = `${target}-alias` + symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + const ctx = await bootHmr(alias) + try { + await watchConfig(ctx, join(alias, 'plugins.yml'), {}, () => {}) + await expect(watchConfig(ctx, join(await realpath(target), 'plugins.yml'), {}, () => {})) + .rejects.toThrow('config path already registered') + } finally { + await ctx.fiber.dispose() + unlinkSync(alias) + rmSync(target, { recursive: true, force: true }) + } + }) + + it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + const observed: string[] = [] + try { + await watchConfig(ctx, filename, {}, () => { + try { + observed.push(readFileSync(filename, 'utf8')) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + observed.push('missing') + } + }) + + writeFileSync(filename, 'one', { flag: 'wx' }) + await eventually(() => observed.includes('one'), 'HMR did not observe config creation') + writeFileSync(filename, 'two') + await eventually(() => observed.includes('two'), 'HMR did not observe config change') + unlinkSync(filename) + await eventually(() => observed.includes('missing'), 'HMR did not observe config removal') + } finally { + await ctx.fiber.dispose() + } + }) + + it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(root) + const dir = join(root, 'later') + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(root) + const observed: string[] = [] + try { + await watchConfig(ctx, filename, {}, () => { + observed.push(readFileSync(filename, 'utf8')) + }) + mkdirSync(dir) + writeFileSync(filename, 'created') + await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent') + } finally { + await ctx.fiber.dispose() + } + }) + + it('serializes refreshes and waits for them during disposal', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + onTestFinished(() => ctx.fiber.dispose()) + const watcher = new FSWatcher() + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + const started = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + let calls = 0 + let active = 0 + let maxActive = 0 + const dispose = await watchConfig(ctx, filename, {}, async () => { + active += 1 + maxActive = Math.max(maxActive, active) + if (++calls === 1) { + started.resolve(undefined) + await release.promise + } + active -= 1 + }) + watcher.emit('change', join(dir, 'unrelated.yml')) + expect(calls).toBe(0) + watcher.emit('add', filename) + await started.promise + watcher.emit('change', filename) + watcher.emit('unlink', filename) + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + release.resolve(undefined) + await disposal + expect(maxActive).toBe(1) + expect(calls).toBe(2) + }) + + it('rejects a patch path whose parent is a regular file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-')) + hmrRoots.push(dir) + const parent = join(dir, 'file') + writeFileSync(parent, '') + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await expect(watchConfig(ctx, join(parent, 'plugins.yml'), {}, () => {})) + .rejects.toThrow('config watch parent is not a directory') + }) + + it('stops searching when the filesystem root cannot be read', async () => { + const failure = Object.assign(new Error('filesystem root unavailable'), { code: 'ENOENT' }) + const read = vi.mocked(fsPromises.stat).mockClear().mockRejectedValueOnce(failure) + onTestFinished(() => { read.mockRestore() }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await expect(watchConfig(ctx, join(parse(tmpdir()).root, 'plugins.yml'), {}, () => {})).rejects.toBe(failure) + expect(read).toHaveBeenCalledOnce() + }) + + it.each(['creation', 'ready'] as const)('releases registration after watcher %s fails', async (phase) => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-watch-failure-')) + hmrRoots.push(dir) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const filename = join(dir, 'plugins.yml') + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + const failure = new Error('watcher unavailable') + const failed = new FSWatcher() + const closed = vi.spyOn(failed, 'close') + configWatch.create = () => { + if (phase === 'creation') throw failure + queueMicrotask(() => { failed.emit('error', failure) }) + return failed + } + await expect(watchConfig(ctx, filename, {}, () => {})).rejects.toBe(failure) + if (phase === 'ready') expect(closed).toHaveBeenCalledOnce() + const watcher = new FSWatcher() + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + await watchConfig(ctx, filename, {}, () => {}) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + watcher.emit('error', failure) + expect(warn).toHaveBeenCalledWith(failure) + }) + + it('closes a ready watcher when its context has already been disposed', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-disposed-')) + hmrRoots.push(dir) + const root = new Context() + const fiber = root.plugin(() => {}) + await fiber + const ctx = fiber.ctx + await fiber.dispose() + const watcher = new FSWatcher() + const close = vi.spyOn(watcher, 'close') + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + await expect(watchConfig(ctx, join(dir, 'plugins.yml'), {}, () => {})) + .rejects.toThrow('cannot create effect on inactive context') + expect(close).toHaveBeenCalledOnce() + }) + + it('logs a normalized refresh failure and continues processing later events', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-failure-')) + hmrRoots.push(dir) + const filename = join(dir, 'plugins.yml') + const ctx = await bootHmr(dir) + onTestFinished(() => ctx.fiber.dispose()) + const watcher = new FSWatcher() + const previousFactory = configWatch.create + onTestFinished(() => { configWatch.create = previousFactory }) + configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher } + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + onTestFinished(() => { warn.mockRestore() }) + let calls = 0 + const recovered = Promise.withResolvers() + await watchConfig(ctx, filename, {}, () => { + if (++calls === 1) throw 42 + recovered.resolve(undefined) + }) + watcher.emit('change', filename) + await expect.poll(() => warn.mock.calls.length).toBe(2) + expect(warn.mock.calls[0]).toEqual(['config reload at %C failed', filename]) + expect(warn.mock.calls[1]?.[0]).toMatchObject({ message: '42' }) + watcher.emit('change', filename) + await recovered.promise + expect(calls).toBe(2) + }) +}) diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 13ea938412..bb453aaa94 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index d9ec52d0b1..f9aca81482 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 39bdacbcdf..abe58f9588 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index d59f4edaab..5d7042c166 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 50a5c664be..90f29b460a 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -67,6 +67,7 @@ function summarize(session: Session, firstSeq: SessionLogOffset): RunOutcome { let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined const length = session.seq for (let seq = firstSeq; seq < length; seq++) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) if (event === undefined) { throw new Error(`headless summary cannot read seq ${String(seq)} below captured length ${String(length)}`) diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index db2fcd33d0..974b60623a 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 6dfc9ee616..bd32a131f0 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 33aa79798d..5c7564c6d3 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: aea694942173a856861d00a69f15b451cc930976 -README.zh.md: 4dab93ff03071bff6783ae89b0397216b86a8c05 +README.md: 29f1737a1a3367c5d1f4e9a37e7031fcd6ae9b9f +README.zh.md: 55e3cc26d8f6b70d464280d2acb2ec0cf37bc937 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index aea6949421..29f1737a1a 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -77,7 +77,7 @@ A patch replaces the targeted row's whole `config`, so each web row restates eve ### Readiness -The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles and Connection authentication is available — or immediately in a hand-built tree without a Loader. A tree disposed mid-boot announces nothing. +The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing. ### LAN trust sampling diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 4dab93ff03..55e3cc26d8 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -77,7 +77,7 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己 ### 就绪宣告 -URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。启动中途被释放的树不会宣告任何内容。 +URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。 ### LAN 信任采样 diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 93a46f6131..56eeaa791f 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 63edf46529..76c212e4e6 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -18,7 +18,7 @@ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' @@ -284,15 +284,17 @@ export function apply(ctx: Context, config: Config): void { const settled = connectionCtx.get('loader')?.await() if (settled === undefined) announceReady() else { - void settled.then(() => { + void settled.then(async () => { + await auditStartupEntries(connectionCtx.root, 'dsh web', () => {}) // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line or browser tab for a dead server would only // mislead, and reading torn-down services would turn a clean shutdown // into a crash. if (connectionCtx.get('webServer') !== undefined && connectionCtx.get('connection') !== undefined) announceReady() - // Loader reports a failed boot; this row only stays quiet. - }, () => {}) + }).catch(() => { + // Boot owns the failure diagnostic; readiness remains unpublished. + }) } }) } diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 3e17147419..d1fef51de2 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -11,8 +11,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as AppBoot from '@deepseek-ai/dsh-app-boot' import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { WebServer } from '@deepseek-ai/dsh-host-webserver' @@ -100,7 +102,7 @@ function provideConnection(ctx: Context): void { /** A fake Loader whose settlement the test controls (the URL line waits on it). */ function provideLoader(ctx: Context, settle: () => Promise = async () => {}): void { - ctx.provide('loader', { await: settle } as never) + ctx.provide('loader', { await: settle, entries: () => [] } as never) } interface BashContribution { @@ -313,6 +315,39 @@ describe('web-app runtime glue', () => { await torn.fiber.dispose() }) + it.each([ + { id: 'webserver', announces: false }, + { id: 'modules', announces: false }, + { id: 'connection', announces: false }, + { id: 'optional-tool', announces: true }, + ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => { + stageDist() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + ctx.baseUrl = 'file:///' + ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.failure = () => { throw new Error('sibling rejected') } + await ctx.loader.root.update([{ id, name: 'cordis:failure' }]) + await ctx.loader.await() + await expect(ctx.loader.resolve(id).fiber?.await()).rejects.toThrow('sibling rejected') + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + const audit = vi.spyOn(AppBoot, 'auditStartupEntries') + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + await vi.waitFor(() => { expect(audit).toHaveBeenCalledOnce() }) + await Promise.allSettled(audit.mock.results.map(result => result.value as Promise)) + if (announces) { + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token') + } else { + expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() + } + }) + it('fails loud when the prompt section resolves against a portless webserver', async () => { stageDist() const ctx = new Context() diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index bf08ff9144..fe288b59a1 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969 -README.zh.md: 5805ad129fc5d1f241fb6594adc7a5f00ca3d0e8 +README.md: 0894d4e0ef5e45876a58abea40cdad840e7dfacd +README.zh.md: 8183ef33ca7e08bec2533eb2e21486809c94cedd diff --git a/packages/client/README.md b/packages/client/README.md index cf4b6aceb3..0894d4e0ef 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | -| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5805ad129f..8183ef33ca 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -71,7 +71,7 @@ kind: "package-group" | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | -| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — | diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index ce13fa2b58..4732dcdd71 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4dfa32343e..b2a9bbcf7d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -34,7 +34,7 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // Type-only: the brand constructor is host-side; the fixture casts at its // wire-fabrication boundary (the schema layer's one-cast-point posture). -import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' @@ -2264,9 +2264,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', attachments: true } }, - { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, + { definitionId: brandString('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '', attachments: true } }, + { definitionId: brandString('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { definitionId: brandString('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ], } }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 24deda96af..590004ef46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -78,9 +78,15 @@ export const inject: string[] = [] * provides both halves here instead of forking this plugin. */ export interface ClientTransportHooks { - /** Transport for generic unary RPC channels (the Typert gateway). */ - fetch: RpcFetch - /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ + /** + * Already decoded logical RPC carrier. When present it replaces the HTTP + * caller outright: no envelopes, no `fetch`, no `openStream` (an in-process + * Host such as a test mock plugs in here). + */ + rpc?: ClientConnectionRpc + /** Transport for generic unary RPC channels (the Typert gateway); unused when `rpc` is present. */ + fetch?: RpcFetch + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket or `rpc` is present. */ openStream?: RpcStreamOpen /** * Bundle transport for the module system, present when the carrier also owns @@ -185,7 +191,7 @@ export function apply(ctx: Context): void { const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ const recovery = resolveConnectionConfig((globalThis as ClientTransportGlobal).__DSH_CONNECTION_RECOVERY__) - const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const rpc = fixtureRpc ?? transport?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 60494dab57..3025767fd5 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -6,8 +6,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, + type ClientConnectionRpc, type ClientTransportHooks, type ConnectionGenerationSource, + type RpcFetch, type ConnectionHandle, type ConnectionState, } from '../src/client/index.ts' @@ -483,6 +485,20 @@ describe('connection client apply', () => { }) }) + it('uses an already decoded rpc carrier from the transport hooks instead of the HTTP caller', async () => { + ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + const rpc: ClientConnectionRpc = { + call: vi.fn(async (_channel: string, endpoint: string, payload: unknown) => ({ ok: true as const, value: { endpoint, payload } })), + open: vi.fn((_channel: string, endpoint: string) => (async function *(): AsyncGenerator { yield endpoint })()), + } + ;(globalThis as Win).__DSH_TRANSPORT__ = { rpc } + const handle = await mount() + expect(handle.rpc).toBe(rpc) + await expect(handle.rpc.call('/api', 'session/list', { args: [] })).resolves.toEqual({ + ok: true, value: { endpoint: 'session/list', payload: { args: [] } }, + }) + }) + it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } const openStream = vi.fn>( @@ -492,7 +508,7 @@ describe('connection client apply', () => { })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { - fetch: vi.fn(), + fetch: vi.fn(), openStream, ownsHost: true, } diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index e179fda33f..5c606edbc5 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 01866dac7a..a45088a92b 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -31,7 +31,9 @@ }, "dsh": { "client": { - "inject": [], + "inject": [ + "@deepseek-ai/dsh-client-modules" + ], "platform": "web", "immediately": true } diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index 66b0e9ad6d..eed2148265 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -75,6 +75,24 @@ export const name = 'client-hmr' /** Required services: the vendored Loader (entry governance) and the client module system (boot provide, service name `modules`). */ export const inject = ['loader', 'modules'] +/** + * Registry-first teardown of an entry's running fiber so `entry.refresh()` + * rebuilds it (see the module comment): delete the runtime record before the + * fiber's disposer emits `internal/plugin` (or the Loader flags the entry + * disabled), drain the unload so effect disposers finish before a new apply + * re-registers, then clear `entry.fiber` so `refresh()` re-imports instead of + * no-oping. A fiberless entry is left untouched. + * @param entry - the Loader entry to tear down. + */ +export async function tearDownEntryFiber(entry: Entry): Promise { + const oldFiber = entry.fiber + if (oldFiber === undefined) return + const runtime = oldFiber.runtime + if (runtime !== null) entry.ctx.registry.delete(runtime.callback) + while (oldFiber.inertia !== undefined) await oldFiber.inertia + delete entry.fiber +} + /** Find the loader entry whose module specifier is `id` (entry tree ids are random; the package name lives in `options.name`). */ function findEntry(loader: Loader, id: string): Entry | undefined { for (const entry of loader.entries()) { @@ -115,18 +133,7 @@ export function apply(ctx: Context): void { modLoader.invalidate(id, rev) await modLoader.prefetch(id) - const oldFiber = entry.fiber - if (oldFiber !== undefined) { - // Registry-first teardown (see module comment): the runtime record must - // be gone before the fiber's disposer emits internal/plugin, or the - // Loader flags the entry disabled. - const runtime = oldFiber.runtime - if (runtime !== null) entry.ctx.registry.delete(runtime.callback) - // Drain the unload: effect disposers (slots, subscriptions) must finish - // before the new bundle executes and the new apply re-registers. - while (oldFiber.inertia !== undefined) await oldFiber.inertia - delete entry.fiber - } + await tearDownEntryFiber(entry) // Old owned styles go before materialization re-injects them (the CSS // idempotency guard keys on stable tag ids). removeOwnedStyles(id) diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 01ce886f49..4245200191 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml index 55c4867520..0cb60fa5d4 100644 --- a/packages/client/modules/README.i18n.yaml +++ b/packages/client/modules/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/modules/README.md -README.md: dad3cd22b111de0d482d74ea4c696467d1a3eeb0 -README.zh.md: ef7a01dd1e2a9ed3074fcb7a768ee0e478975fa4 +README.md: 4c9dc4a3a13cb6f24e03c927d0277e137ab97b9f +README.zh.md: bc5b78d258270962661ab27ca7eb1a58cc61f01c diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md index dad3cd22b1..4c9dc4a3a1 100644 --- a/packages/client/modules/README.md +++ b/packages/client/modules/README.md @@ -71,6 +71,8 @@ The Node half snapshots each client bundle and available source map before publi ### Boot manifest injection +The bundle route follows the injected `webServer` lifetime: it registers when the service is ready and is removed and re-registered when that service is replaced. Module composition and `fetchBundle()` remain available without a Web server. + The host contributes structured index rows that inject, into ``: the `window.__ModuleLoader__` queue facade, advisory preloads for every application combo, the parser-blocking bootstrap combo scripts, then the boot graph before the shell reads it. A Web carrier renders those rows into its index response; a shell-owned carrier can render the same rows without a Web server. The facade's `create()` materializes the modules bundle, delegates construction to its `createClientModuleSystem` export, and leaves the same facade in live-registration mode. ### Source map @@ -80,7 +82,7 @@ The host contributes structured index rows that inject, into ``: the `wind | [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows | | [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment | | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery | -| [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types and boot-manifest parsing | +| [`src/client/manifest.ts`](src/client/manifest.ts) | Wire types, boot-manifest parsing, and the `dsh.client` declaration parser | diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md index ef7a01dd1e..bc5b78d258 100644 --- a/packages/client/modules/README.zh.md +++ b/packages/client/modules/README.zh.md @@ -71,6 +71,8 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map ### 启动 manifest 注入 +bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册,服务被替换时移除并重新注册。模块组合与 `fetchBundle()` 在没有 Web server 时仍可用。 + 宿主贡献结构化 index 行,并向 `` 注入:`window.__ModuleLoader__` queue facade、每个 application combo 的提示性 preload、阻塞 parser 的 bootstrap combo 脚本,然后才是外壳读取前的启动图。Web 载体把这些行渲染进 index 响应;由 shell 持有的载体则可以在没有 Web server 时渲染同一批行。facade 的 `create()` 物化 modules bundle、把构造委托给其 `createClientModuleSystem` 导出,并让同一 facade 进入 live registration 模式。 ### 源码索引 @@ -80,7 +82,7 @@ Node 半侧会在发布前快照每个客户端 bundle 及其现有 source map | [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 | | [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 | | [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 | -| [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型与启动 manifest 解析 | +| [`src/client/manifest.ts`](src/client/manifest.ts) | 协议类型、启动清单解析与 `dsh.client` 声明解析器 | diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 12348841cb..06ee59bb3e 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index c2c6a604c8..d7bf39e518 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -18,7 +18,7 @@ import type { } from './manifest.ts' export { ClientModuleSystem } -export { parseBootManifest, stripClientSuffix } from './manifest.ts' +export { exactPackageSpecifier, parseBootManifest, parseDshClient, stripClientSuffix } from './manifest.ts' export type { BootManifest, BootModuleRow, BootPluginRow, ClientBootstrapModule, ClientBundleRegistration, ClientModuleCreateOptions, ClientModuleLoader, ClientModuleLoaderTarget, ClientModuleRecord, diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index e8feb2ee15..d6f8f99b8f 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -30,6 +30,7 @@ */ import type {} from '@deepseek-ai/cordis' +import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest' import type { ClientModuleSystem } from './system.ts' declare module '@deepseek-ai/cordis' { @@ -144,6 +145,51 @@ export function optionalStringArray(subject: string, field: string, value: unkno return value as string[] } +/** + * Narrow an unknown parsed JSON value to the `dsh.client` declaration. Shared + * by the node half's Loader scan and the roster generator, so both read a + * package's browser declaration through one validator. + * @param pkgName - package name used as the diagnostic prefix. + * @param value - the raw `dsh.client` field of the package manifest. + * @returns the validated declaration, or undefined when the field is absent. + * @throws {Error} when the field is present but any member is malformed. + */ +export function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined { + if (value === undefined) return undefined + if (typeof value !== 'object' || value === null) { + throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`) + } + const decl = value as Record + if (typeof decl.platform !== 'string') { + throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`) + } + const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject) + const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external) + if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { + throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`) + } + return { + platform: decl.platform, + ...(inject !== undefined ? { inject } : {}), + ...(external !== undefined ? { external } : {}), + ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), + } +} + +/** + * The bare package-root specifier `specifier` names, or undefined for a subpath, a path, or any scheme-qualified + * specifier (`cordis:` builtins, `node:` modules, URLs). + * @param specifier - Loader row name. + * @returns the package name, or undefined. + */ +export function exactPackageSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('@')) { + const parts = specifier.split('/') + return parts.length === 2 && parts.every(Boolean) ? specifier : undefined + } + return specifier.length > 0 && !specifier.includes('/') && !specifier.includes(':') ? specifier : undefined +} + /** * Normalize a module specifier onto the graph row that owns it: a plugin bundle * IS its package's client half, so `/client` (the exports subpath external diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index c823657be5..a9c1988745 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -33,8 +33,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { Entry } from '@deepseek-ai/cordis-plugin-loader' import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' -import type { DshClientManifest } from '@deepseek-ai/dsh-package-manifest' -import { optionalStringArray, stripClientSuffix } from './client/manifest.ts' +import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts' import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph } from './client/manifest.ts' export { stripClientSuffix } from './client/manifest.ts' @@ -173,38 +172,6 @@ const SOURCE_MAP_TRAILER = /(?:\r?\n)?\/\/# sourceMappingURL=[^\r\n]*(?:\r?\n)?$ /** Debugger source name appended to page bundles in the WebWorker image. */ const SOURCE_URL_TRAILER = /(?:\r?\n)?\/\/# sourceURL=([^\r\n]+)(?:\r?\n)?$/ -/** Return a bare package-root specifier, excluding package subpaths and path-like entries. */ -function exactPackageSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('@')) { - const parts = specifier.split('/') - return parts.length === 2 && parts.every(Boolean) ? specifier : undefined - } - return specifier.length > 0 && !specifier.includes('/') ? specifier : undefined -} - -/** Narrow an unknown parsed JSON value to the `dsh.client` declaration, throwing on malformed fields. */ -function parseDshClient(pkgName: string, value: unknown): DshClientManifest | undefined { - if (value === undefined) return undefined - if (typeof value !== 'object' || value === null) { - throw new Error(`client-modules: ${pkgName} has a non-object dsh.client declaration`) - } - const decl = value as Record - if (typeof decl.platform !== 'string') { - throw new Error(`client-modules: ${pkgName} dsh.client.platform must be a string`) - } - const inject = optionalStringArray(pkgName, 'dsh.client.inject', decl.inject) - const external = optionalStringArray(pkgName, 'dsh.client.external', decl.external) - if (decl.immediately !== undefined && typeof decl.immediately !== 'boolean') { - throw new Error(`client-modules: ${pkgName} dsh.client.immediately must be a boolean`) - } - return { - platform: decl.platform, - ...(inject !== undefined ? { inject } : {}), - ...(external !== undefined ? { external } : {}), - ...(decl.immediately !== undefined ? { immediately: decl.immediately } : {}), - } -} - /** Resolve `exports["./client"]` to a relative path, accepting the string and one-level conditional forms. */ function clientExportOf(pkgName: string, exportsField: unknown): string | undefined { if (typeof exportsField !== 'object' || exportsField === null) return undefined @@ -537,6 +504,7 @@ export class ClientModuleRegistry extends Service { /** * Build the service: subscribe, seed, and run the activation flush. + * Bundle routes follow the optional Web carrier's injected lifecycle. * @param ctx - plugin context carrying Loader and an optional Web carrier. */ constructor(ctx: Context) { @@ -573,8 +541,7 @@ export class ClientModuleRegistry extends Service { 'client-modules: bundle route', ) } - if (ctx.get('webServer') === undefined) ctx.inject(['webServer'], registerWebCarrier) - else registerWebCarrier(ctx) + ctx.inject(['webServer'], registerWebCarrier) ctx.on('webserver/index-inject', (table) => { table.push(...bootInjections(this.composed)) }) diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 4f06360ff6..95a5396e91 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' -import { Context, type Fiber } from '@deepseek-ai/cordis' +import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' @@ -24,12 +24,50 @@ const BOOTSTRAP_URL = comboUrl([MODULES_ID], 'boot') const APPLICATION_URL = comboUrl([UI_RENDERER_ID], 'app') let root: string | undefined +const contexts: { ctx: Context; ready?: Promise }[] = [] -afterEach(() => { +afterEach(async () => { + await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => { + await ready + await ctx.fiber.dispose() + })) if (root !== undefined) rmSync(root, { recursive: true, force: true }) root = undefined }) +it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => { + const ctx = new Context() + contexts.push({ ctx }) + ctx.provide('loader', { entries: () => [] }) + const routes = new Set() + const mountServer = () => ctx.plugin((serverCtx) => { + serverCtx.provide('webServer', { + register: (route: WebRoute) => { + routes.add(route) + return () => { routes.delete(route) } + }, + } as WebServer) + }) + let server = serverFirst ? await mountServer() : undefined + const modules = await ctx.plugin(ClientModuleRegistry) + const service = ctx.get('clientModules')! + expect(service.graph().entries).toEqual([]) + expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404) + if (!serverFirst) { + expect(routes.size).toBe(0) + server = await mountServer() + } + await expect.poll(() => routes.size).toBe(1) + await server!.dispose() + await expect.poll(() => routes.size).toBe(0) + expect(modules.state).toBe(FiberState.ACTIVE) + expect(service.fetchBundle(new Request('http://localhost/plugins/missing')).status).toBe(404) + await mountServer() + await expect.poll(() => routes.size).toBe(1) + await modules.dispose() + expect(routes.size).toBe(0) +}) + /** Create a resolvable package whose client export points at the returned path. */ function writePackage( packageName: string, @@ -65,8 +103,10 @@ function constructWithRoute( entryBaseUrl?: string internal?: NonNullable } = {}, -): { context: Context; service: ClientModuleRegistry; route: WebRoute } { +): { context: Context; service: ClientModuleRegistry; route: Promise } { const ctx = new Context() + const owned: typeof contexts[number] = { ctx } + contexts.push(owned) ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/' ctx.provide('loader', { internal: options.internal, @@ -81,19 +121,19 @@ function constructWithRoute( } }, }) - let route: WebRoute | undefined + const route = Promise.withResolvers() const webServer: Pick = { port: 0, register: (candidate) => { - if (candidate.path === '/plugins') route = candidate + if (candidate.path === '/plugins') route.resolve(candidate) return () => {} }, tapIndex: () => () => {}, } ctx.provide('webServer', webServer as WebServer) const service = new ClientModuleRegistry(ctx) - if (route === undefined) throw new Error('client bundle route was not registered') - return { context: ctx, service, route } + owned.ready = route.promise + return { context: ctx, service, route: route.promise } } /** Construct the node-half service over the enabled fixture entries. */ @@ -102,7 +142,7 @@ function construct(packageNames: string[]): ClientModuleRegistry { } /** Invoke the registered plugin route and capture status, headers, and bytes. */ -async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promise<{ +async function routeRequest(route: Promise, url: string, method = 'GET'): Promise<{ status: number headers: Record | undefined body: Buffer @@ -121,7 +161,7 @@ async function routeRequest(route: WebRoute, url: string, method = 'GET'): Promi return response }, } as unknown as ServerResponse - await route.handler({ method, url } as IncomingMessage, response) + await (await route).handler({ method, url } as IncomingMessage, response) return { status, headers, body } } diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index e1b71668f9..dd6b9baa2f 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 772dbc2ba9..59325a1517 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index f941d9eb93..2dce990410 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index a240cee720..765176239f 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 1bb4b119de..f994246609 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 9407f6900f..1113b8a013 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 7755e2e0a9..2c5c3fda05 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 2499163dcc0795278ce67bed38be2ed106bbae20 -README.zh.md: 33fa9e30fd0c9e46cd9c600decd89e4dc0357921 +README.md: 2333739f2827eb673525b9f00547d12e8d343888 +README.zh.md: 0de859abfcfac1679d605cf4447a2f4ac2312f5d diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 2499163dcc..2333739f28 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -17,6 +17,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [Reference previews](#reference-previews) - [System prompt row](#system-prompt-row) - [Turn token usage](#turn-token-usage) +- [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) - [Scroll ownership](#scroll-ownership) - [Model Experience](#model-experience) @@ -40,6 +41,11 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total. + +## Completed-turn footer + +The completed-turn action footer starts 20px below the preceding prose or extension content. + ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 33fa9e30fd..0de859abfc 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -17,6 +17,7 @@ kind: "package-reference" - [引用预览](#reference-previews) - [系统提示词行](#system-prompt-row) - [轮次 token 用量](#turn-token-usage) +- [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) - [滚动归属](#scroll-ownership) - [模型体验](#model-experience) @@ -42,6 +43,11 @@ kind: "package-reference" 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。 + +## 已完成轮次的页脚 + +已完成轮次的操作页脚位于前方正文或扩展内容下方 20px。 + ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index cc83e1675d..1caf5c0aec 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css index 831e6e212b..bfa386d31c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css @@ -5,5 +5,8 @@ } .actions { + /* With tail content, this adds to the root gap; without it, the Chat flow's + preceding-item offset supplies the other 16px. */ + margin-top: 4px; margin-left: -6px; } diff --git a/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts new file mode 100644 index 0000000000..1955fd41cd --- /dev/null +++ b/packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts @@ -0,0 +1,16 @@ +/** CSS checks for the completed-turn footer's 20px content spacing. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const read = (name: string): string => + readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8') + +describe('completed-turn spacing', () => { + it('combines the flow and footer offsets around turn-tail content', () => { + expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 16px\)/) + const tail = read('TurnTailNodeView.module.css') + expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s) + expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s) + }) +}) diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 4557db8d9f..b5f59c6b70 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md -README.md: 67c05bb2cd22f63e386c4646dfb3472d9e01eea1 -README.zh.md: f5701c491bd4127106a32d936f6434c1e572e3e5 +README.md: 147cce99ab24ac3c1a8a111b4b95f4175f89d26b +README.zh.md: 11f47e1e64915198295ee20ee6e051c413d30dc8 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 67c05bb2cd..147cce99ab 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`: a popupSelect spec (`/model`, `/permission`) or an action (`/feedback`), registered as a command or decorating an existing host command while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is its kind, and everything else is `execute`. +Typing a `/` command opens a registered popup, a client action, a host command's input, or direct execution; a command line is never silently downgraded to a plain prompt. Business packages register popupSelect specs (`/model`, `/permission`) or actions through `ctx.commandUi`, or decorate existing host commands with either kind while preserving their catalog rows and argument claims. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect` or `action`, and everything else is `execute`. ## Table of Contents @@ -25,11 +25,15 @@ Typing a `/` command in the composer opens the matching surface — a registered ## Use this package -Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. +Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it. ### Kinds and decorations -A contribution is a client-owned command — a host-name collision fails loudly. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. +A contribution is a client-owned command; a host-name collision fails loudly. Its UI is a popupSelect spec or an action: a callback a bare invocation runs after the trigger token is consumed, without submitting a message. Business packages own their actions and availability; the composer registers File through this same API. A decoration adds a bare-invocation popup or action to an existing host command while preserving its catalog row, argument claim, and lifecycle logging; it never fires without a matching host row. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names and titles, with prefixes first and no section headings. + +### Built-in row faces + +First-party command definitions carry stable `definitionId` values. The client selects their localized titles, descriptions, icons, and input spellings by identity; changing a Host description cannot change that selection. Same-name overrides without the matching identity keep their own copy and receive no first-party aliases. Chinese and English spellings resolve through the same effective Session catalog in every locale, preserving the typed spelling in the draft and submitting the registered Host name. Contributions supply their own `label`, `description`, and `icon`, read on every candidate pass. Empty-query section order follows names, with unlisted rows closing Commands. ### Attachment-carrying submissions @@ -43,7 +47,7 @@ When the composer submits with images or generic files, only a host command decl
Implementation internals — click to expand -`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. +`src/client/contract.ts` defines contribution and decoration registration. `CommandDirectory` owns the per-session wire cache and resolves typed commands through `resolution.ts`; that module owns first-party identity matching and localized input spellings. `matchSpace` reads the ready cache synchronously, while `matchEnter` waits for readiness and rejects on warmup failure or cancellation. Forwarded catalog and connection events invalidate the cache. After a matched Host execution, this browser emits `command/executed`; other clients observe only the durable command events. `PopupSelectController` owns popup state, and `PopupSelectView` occupies the input overlay. `presentation.ts` owns row labels, icons, and sections; its helpers and the resolution helpers stay internal to the plugin.
diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index f5701c491b..11f47e1e64 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:popupSelect 贡献项(`/model`、`/permission`)或 action(`/feedback`),既可注册为命令,也可装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的按其种类派发,其余全部是 `execute`。 +键入 `/` 命令会打开已注册的弹窗、运行客户端动作、进入宿主命令的输入或直接执行,命令行不会被静默降级为普通提示词。业务包通过 `ctx.commandUi` 注册 popupSelect(`/model`、`/permission`)或 action,也可用这两种方式装饰既有宿主命令,同时保留其目录行与参数声明。空格与回车根据会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect` 或 `action`,其余是 `execute`。 ## 目录 @@ -25,11 +25,15 @@ kind: "package-reference" ## 使用本包 -与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。 +与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。 ### 种类与装饰 -贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 +贡献项是客户端自有命令,与宿主命令同名会明确报错。它的 UI 是 popupSelect 规格或动作:裸调用消费触发 token 后运行回调,不提交消息。业务包负责自己的动作及可用性,输入框通过同一 API 注册「文件」。装饰为已有宿主命令添加裸调用弹窗或动作,并保留其目录行、参数认领与生命周期记录;没有匹配的宿主行时不触发。菜单查询按顺序、不区分大小写地模糊匹配命令名与标题的子序列,前缀优先,不显示小节标题。 + +### 内置行的展示面 + +内置命令定义携带稳定的 `definitionId`。客户端按标识选择本地化标题、说明、图标和输入写法,修改宿主说明不会改变选择结果。没有匹配标识的同名覆盖保留自己的文案,也不获得内置别名。在任何界面语言下,中英文写法都通过同一个会话有效目录解析,草稿保留手输写法,提交使用宿主注册名。贡献项提供自己的 `label`、`description` 和 `icon`,每次生成候选项时读取。空查询按名称确定小节顺序,未列出的行排在「指令」末尾。 ### 带附件提交 @@ -43,7 +47,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
实现细节——点击展开 -`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。 +`src/client/contract.ts` 定义贡献项和装饰的注册接口。`CommandDirectory` 负责会话级协议缓存,并通过 `resolution.ts` 解析输入命令;该模块负责内置命令标识匹配和本地化输入写法。`matchSpace` 同步读取就绪缓存,`matchEnter` 等待缓存就绪,预热失败或取消时拒绝。转发的目录和连接事件使缓存失效。宿主执行匹配的命令后,本浏览器发布 `command/executed`,其他客户端只观察持久命令事件。`PopupSelectController` 负责弹窗状态,`PopupSelectView` 占据输入浮层。`presentation.ts` 负责行标题、图标和分节,展示与解析辅助函数均留在插件内部。
diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 46538d757a..f3313850a3 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 326f933d02..b81c1a44f9 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -3,8 +3,10 @@ * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages * consume `register` alone. */ +import type { ComponentType } from 'react' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' /** Copy for an option that must be acknowledged before onSelect can run. */ export interface SelectConfirmation { @@ -39,8 +41,7 @@ export interface PopupSelectSpec { /** * Business registration for the action command kind: a bare invocation - * consumes the trigger token and runs one client-side callback (the Feedback - * row opens the feedback dialog). It submits nothing, so an + * consumes the trigger token and runs one client-side callback. It submits nothing, so an * attachment-carrying draft never refuses it. */ export interface ActionSpec { @@ -59,13 +60,18 @@ export type CommandUiSpec = PopupSelectSpec | ActionSpec * One client-owned command contribution: a slash-menu entry whose behavior * lives entirely on the client (no host descriptor). Merged with the host * catalog by name — a collision with a host command fails loud at candidate - * synthesis, never shadows. + * synthesis, never shadows. Row copy is read on every candidate pass, so a + * locale change reaches the next menu open without re-registration. */ export interface CommandContribution { /** Command name without the leading slash (unique across contributions). */ readonly name: string - /** Resolve the localized menu row description when candidates are requested. */ - readonly description: () => string + /** Localized menu row title; the name itself when absent. */ + label?(): string + /** Localized menu row description; the row shows none when absent. */ + description?(): string + /** Menu row glyph from the shared icon set. */ + readonly icon?: ComponentType /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean /** The command's UI behavior. */ diff --git a/packages/client/ui-commands/src/client/directory.ts b/packages/client/ui-commands/src/client/directory.ts index 10e3df8eab..524e0352b9 100644 --- a/packages/client/ui-commands/src/client/directory.ts +++ b/packages/client/ui-commands/src/client/directory.ts @@ -7,6 +7,7 @@ */ import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { resolveCommand } from './resolution.ts' export type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' @@ -46,15 +47,15 @@ export class CommandDirectory { } /** - * Synchronous exact-name lookup over one session's hot snapshot. + * Synchronous command lookup over one Session's ready catalog; exact names precede localized aliases. * @param sessionId - session key. - * @param name - command name without the leading slash. + * @param name - typed command spelling without the leading slash. * @returns the descriptor, or undefined when absent or the entry is not ready. */ resolve(sessionId: SessionId, name: string): CommandDescriptor | undefined { const entry = this.entries.get(sessionId) if (entry === undefined || entry.state !== 'ready') return undefined - return entry.commands.find(c => c.name === name) + return resolveCommand(name, entry.commands) } /** Soft invalidation (commands-changed): background repull on every touched key; ready snapshots keep serving. */ diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index 0c1240ab76..892e78ff0a 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -40,7 +40,7 @@ declare module '@deepseek-ai/cordis' { declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** The popupSelect shell's copy. */ + /** The menu rows' and the popupSelect shell's copy. */ command: CommandKey } } @@ -52,8 +52,7 @@ const NS = 'command' export const inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands', 'locale'] /** - * Client plugin body: mount the service, then register the popupSelect shell - * into the input overlay once its declarer is up. + * Mount the command service and its per-session popupSelect overlay. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 0a875d467c..fd986c2a1f 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -1,13 +1,32 @@ -/** `command` namespace dictionaries (the popupSelect shell's copy). */ +/** + * `command` namespace dictionaries: the composer menu's section headings, + * the client face (title, description, claim token) of the built-in Host + * commands whose catalog descriptors carry English text only, and the + * popupSelect shell's copy. + */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'description.compact': '压缩以上对话内容', - 'description.export': '将当前会话内容导出为 ZIP', - 'description.feedback': '发送关于当前会话的反馈', + 'section.add': '添加', + 'section.commands': '指令', + 'label.goal': '目标', + 'label.plan': '计划', + 'label.feedback': '反馈', + 'label.compact': '压缩', + 'label.permission': '权限', + 'label.export': '下载日志', 'description.goal': '设置或查看长期任务目标', - 'description.permission': '切换权限预设(沙箱模式与审批策略)', 'description.plan': '进入或退出计划模式', + 'description.feedback': '发送关于当前会话的反馈', + 'description.compact': '压缩以上对话内容', + 'description.permission': '切换权限预设(沙箱模式与审批策略)', + 'description.export': '将当前会话内容导出为 ZIP', + 'token.goal': '目标', + 'token.plan': '计划', + 'token.feedback': '反馈', + 'token.compact': '压缩', + 'token.permission': '权限', + 'token.export': '导出', 'search.placeholder': '搜索…', 'search.aria': '筛选选项', 'status.loading': '正在加载选项…', @@ -23,12 +42,26 @@ export type CommandKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - 'description.compact': 'Compact older conversation history', - 'description.export': 'Download this Session log as a ZIP archive', - 'description.feedback': 'record feedback about this session', - 'description.goal': 'set or view the goal for a long-running task', - 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', + 'section.add': 'Add', + 'section.commands': 'Commands', + 'label.goal': 'Goal', + 'label.plan': 'Plan', + 'label.feedback': 'Feedback', + 'label.compact': 'Compact', + 'label.permission': 'Permission', + 'label.export': 'Export', + 'description.goal': 'Set or view the goal for a long-running task', 'description.plan': 'Enter or leave plan mode', + 'description.feedback': 'Record feedback about this session', + 'description.compact': 'Compact older conversation history', + 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', + 'description.export': 'Download this Session log as a ZIP archive', + 'token.goal': 'goal', + 'token.plan': 'plan', + 'token.feedback': 'feedback', + 'token.compact': 'compact', + 'token.permission': 'permission', + 'token.export': 'export', 'search.placeholder': 'Search…', 'search.aria': 'Filter options', 'status.loading': 'Loading options…', diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts new file mode 100644 index 0000000000..49ffb3da4b --- /dev/null +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -0,0 +1,86 @@ +/** Composer menu grouping, localized labels, descriptions, and icons. */ +import type { ComponentType } from 'react' +import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { + IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPlanOutline14, IconSendOutline16, + IconShieldOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' +import type { CommandKey } from './locales.ts' +import { builtinCommandName } from './resolution.ts' +import type { BuiltinCommandName } from './resolution.ts' + +/** The menu's two sections. */ +export type MenuSection = 'add' | 'commands' + +/** Row names per section, highest usage first; rows outside both lists close the Commands section in catalog order. */ +const SECTION_ROWS: Readonly> = { + add: ['file', 'goal', 'plan', 'feedback'], + commands: ['compact', 'permission', 'model', 'export'], +} + +/** The dictionary keys and glyph of one built-in Host command's client face. */ +interface HostFace { + readonly label: CommandKey + readonly description: CommandKey + readonly icon: ComponentType +} + +/** One built-in Host command's face, keyed by its dictionary entries. */ +function hostFace(name: BuiltinCommandName, icon: ComponentType): readonly [BuiltinCommandName, HostFace] { + return [name, { + label: `label.${name}`, + description: `description.${name}`, + icon, + }] +} + +/** Built-in Host commands whose client face this package owns. */ +const HOST_FACES: ReadonlyMap = new Map([ + hostFace('goal', IconGoalOutline16), + hostFace('plan', IconPlanOutline14), + hostFace('feedback', IconSendOutline16), + hostFace('compact', IconCompactOutline16), + hostFace('permission', IconShieldOutline16), + hostFace('export', IconDownloadOutline16), +]) + +/** + * The localized menu face of a catalog row. + * @param descriptor - effective Host command descriptor. + * @param t - the `command` namespace translator. + * @returns title, description, and glyph for a built-in command; undefined + * for any other row, which keeps its catalog description. + */ +export function builtinRowFace( + descriptor: CommandDescriptor, + t: TranslateNS<'command'>, +): Pick | undefined { + const name = builtinCommandName(descriptor) + const face = name === undefined ? undefined : HOST_FACES.get(name) + return face === undefined ? undefined : { label: t(face.label), description: t(face.description), icon: face.icon } +} + +/** + * Arrange the empty-query menu: the Add section, then the Commands section, + * each in usage order, with unlisted rows closing Commands in their input + * order; each row carries its section heading. + * @param rows - the visible candidates in catalog-then-contribution order. + * @param t - the `command` namespace translator. + * @returns the sectioned rows. + */ +export function sectionRows(rows: readonly InputTriggerCandidate[], t: TranslateNS<'command'>): readonly InputTriggerCandidate[] { + const listed = new Set([...SECTION_ROWS.add, ...SECTION_ROWS.commands]) + const byName = new Map(rows.map(row => [row.name, row])) + const pick = (names: readonly string[]): InputTriggerCandidate[] => + names.flatMap((name) => { + const row = byName.get(name) + return row === undefined ? [] : [row] + }) + const add = pick(SECTION_ROWS.add).map(row => ({ ...row, section: t('section.add') })) + const commands = [...pick(SECTION_ROWS.commands), ...rows.filter(row => !listed.has(row.name))] + .map(row => ({ ...row, section: t('section.commands') })) + return [...add, ...commands] +} diff --git a/packages/client/ui-commands/src/client/resolution.ts b/packages/client/ui-commands/src/client/resolution.ts new file mode 100644 index 0000000000..7b4951cba3 --- /dev/null +++ b/packages/client/ui-commands/src/client/resolution.ts @@ -0,0 +1,59 @@ +/** Command identity and localized input spelling over the effective Host catalog. */ +import type { CommandDescriptor } from '@deepseek-ai/dsh-commands/types' +import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { en, zh } from './locales.ts' + +const BUILTINS = { + goal: '@deepseek-ai/dsh-command-goal', + plan: '@deepseek-ai/dsh-plan-mode', + feedback: '@deepseek-ai/dsh-command-feedback', + compact: '@deepseek-ai/dsh-command-compact', + permission: '@deepseek-ai/dsh-permission-presets', + export: '@deepseek-ai/dsh-session-log-export', +} as const + +/** Names whose first-party definitions have localized client presentation. */ +export type BuiltinCommandName = keyof typeof BUILTINS + +/** + * Identify a first-party definition without interpreting its display copy. + * @param descriptor - effective Host descriptor after scoped shadowing. + * @returns its first-party name, or undefined for another definition. + */ +export function builtinCommandName(descriptor: CommandDescriptor): BuiltinCommandName | undefined { + return (Object.keys(BUILTINS) as BuiltinCommandName[]) + .find(name => descriptor.definitionId === BUILTINS[name]) +} + +/** + * Select the input spelling for a menu-picked command. + * @param descriptor - effective Host descriptor. + * @param t - command-namespace translator. + * @returns localized spelling for a known definition, otherwise its registered name. + */ +export function claimToken(descriptor: CommandDescriptor, t: TranslateNS<'command'>): string { + const name = builtinCommandName(descriptor) + return name === undefined ? descriptor.name : t(`token.${name}`) +} + +const TOKEN_ALIASES = new Map( + (Object.keys(BUILTINS) as BuiltinCommandName[]).flatMap(name => + [zh[`token.${name}`], en[`token.${name}`]].map(token => [token, name] as const)), +) + +/** + * Resolve typed spelling against the current Session's effective definitions. + * @param token - typed name without its leading slash. + * @param descriptors - effective descriptors in the Session's ready catalog. + * @returns the matching descriptor; aliases never select an unrelated scoped override. + */ +export function resolveCommand( + token: string, + descriptors: readonly CommandDescriptor[], +): CommandDescriptor | undefined { + const exact = descriptors.find(descriptor => descriptor.name === token) + if (exact !== undefined) return exact + const name = TOKEN_ALIASES.get(token) + if (name === undefined) return undefined + return descriptors.find(descriptor => descriptor.definitionId === BUILTINS[name]) +} diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 59f885bb6d..b3055b1d7a 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -2,10 +2,12 @@ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then position filtering and - * the `/` menu's shared name ranking (ui-primitives `rankByName`); a - * host/contribution name collision fails loud. Every execute - * addresses the session's agent by sessionId — sessions are always + * catalog with contributions by availability, gives built-in Host rows their + * localized face (presentation.ts), then position-filters; an empty query + * lists the Add and Commands sections in usage order, a typed query ranks + * every row by the `/` menu's shared name-and-label ranking (ui-primitives + * `rankByName`). A host/contribution name collision fails loud. Every + * execute addresses the session's agent by sessionId — sessions are always * agent-backed. */ import { Service } from '@deepseek-ai/cordis' @@ -26,8 +28,9 @@ import type { import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' -import { en, type CommandKey } from './locales.ts' import { PopupSelectController } from './popup.ts' +import { builtinRowFace, sectionRows } from './presentation.ts' +import { claimToken } from './resolution.ts' import type { TokenSegment } from './popup.ts' declare module '@deepseek-ai/cordis' { @@ -59,16 +62,6 @@ interface LiveState { readonly popups: Map> } -/** Locale keys for the canonical first-party Host command descriptions. */ -const HOST_DESCRIPTION_KEYS = new Map([ - ['compact', 'description.compact'], - ['export', 'description.export'], - ['feedback', 'description.feedback'], - ['goal', 'description.goal'], - ['permission', 'description.permission'], - ['plan', 'description.plan'], -]) - /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -196,7 +189,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } } - /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */ + /** + * Menu candidates: host catalog + contribution availability, built-in rows + * localized, then position filtering; sections for an empty query, the + * shared name-and-label ranking for a typed one. + */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: InputTriggerCandidate[] = [] @@ -205,7 +202,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { seen.add(c.name) rows.push({ name: c.name, - description: this.hostDescription(c), + ...(builtinRowFace(c, this.t) ?? { description: c.description }), ...(c.input !== undefined ? { hint: c.input.hint } : {}), }) } @@ -214,18 +211,15 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (seen.has(contribution.name)) { throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`) } - rows.push({ name: contribution.name, description: contribution.description() }) + rows.push({ + name: contribution.name, + ...(contribution.label === undefined ? {} : { label: contribution.label() }), + ...(contribution.description === undefined ? {} : { description: contribution.description() }), + ...(contribution.icon === undefined ? {} : { icon: contribution.icon }), + }) } - return rankByName( - rows.filter(c => req.position === 'leading' || c.hint === undefined), - req.query, - ) - } - - /** Translate exact built-in Host copy while preserving scoped or third-party descriptors verbatim. */ - private hostDescription(command: CommandDescriptor): string { - const key = HOST_DESCRIPTION_KEYS.get(command.name) - return key !== undefined && command.description === en[key] ? this.t(key) : command.description + const visible = rows.filter(c => req.position === 'leading' || c.hint === undefined) + return req.query === '' ? sectionRows(visible, this.t) : rankByName(visible, req.query) } /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */ @@ -246,7 +240,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } - if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } + if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session, claimToken(desc, this.t)) } // Menu-pick execute consumes the trigger span before the detached run // (scoped event; the input owns the CAS guard). this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span }) @@ -257,11 +251,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract { /** Decision table, space column: hot-key sync check; only host leadingInput claims. */ private matchSpace(session: ClientSessionContext, token: string): PickOutcome { if (!token.startsWith('/')) return undefined - const name = token.slice(1) - if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space - const desc = this.directory.resolve(session.sessionId, name) + if (this.live.contributions.has(token.slice(1))) return undefined // popup and action kinds never claim on space + const desc = this.directory.resolve(session.sessionId, token.slice(1)) if (desc === undefined || desc.input === undefined) return undefined - return { claim: this.leadingClaim(desc, session) } + return { claim: this.leadingClaim(desc, session, token.slice(1)) } } /** @@ -276,6 +269,9 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * refusal so the machine surfaces one composer notice and the draft and * attachments stay in place; nothing executes and nothing is dropped. An * action submits nothing and runs regardless. + * + * A typed token is resolved through the localized claim tokens, so a line + * written as `/计划` reaches the `plan` descriptor and executes as `/plan`. */ private async matchEnter( session: ClientSessionContext, @@ -288,21 +284,23 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const ws = trimmed.search(/\s/) const token = ws === -1 ? trimmed : trimmed.slice(0, ws) const bare = ws === -1 - const name = token.slice(1) - if (name === '') return undefined + const typedName = token.slice(1) + if (typedName === '') return undefined const refuseAttachments = (): never => { - throw new Error(this.t('notice.attachmentsUnsupported', { command: name })) + throw new Error(this.t('notice.attachmentsUnsupported', { command: typedName })) } - const contribution = this.live.contributions.get(name) + const contribution = this.live.contributions.get(typedName) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments() - this.invoke(name, contribution.ui, session, { via: 'enter', token }) + this.invoke(typedName, contribution.ui, session, { via: 'enter', token }) return 'handled' } await this.directory.ensureReady(session.sessionId, signal) - const desc = this.directory.resolve(session.sessionId, name) + const desc = this.directory.resolve(session.sessionId, typedName) if (desc === undefined) return undefined + const name = desc.name + const canonical = `/${name}${trimmed.slice(token.length)}` // Bare enter on a decorated host command opens its popup; an argued line // never consults the decoration (the claim/detached paths below own it). if (bare) { @@ -315,12 +313,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } if (desc.input !== undefined) { if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments() - return { claim: this.leadingClaim(desc, session) } + return { claim: this.leadingClaim(desc, session, token.slice(1)) } } if (!bare) return undefined if (envelope.attachments > 0) refuseAttachments() this.consumeVia(session.sessionId, { via: 'enter', token }) - this.runDetached(desc, session, trimmed) + this.runDetached(desc, session, canonical) return 'handled' } @@ -344,14 +342,22 @@ export class CommandUiRuntime extends Service implements CommandUiContract { this.popupFor(actx).open(name, ui, session, segment) } - /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */ - private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim { - const token = `/${desc.name} ` + /** + * Build the leadingInput claim. The composer keeps the claimed token in + * the draft and reads the arguments after it, so the token is the spelling + * the draft will carry: the locale's token for a menu pick, the typed + * spelling for Space and Enter. The command.execute submit transaction + * always sends the catalog name. + */ + private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext, shown: string): CommandClaim { + const token = `/${shown} ` + const line = `/${desc.name} ` return { + name: desc.name, token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), ...(desc.input?.attachments === true ? { attachments: true } : {}), - submit: (args, _actx, attachments) => this.execute(session, token + args, attachments), + submit: (args, _actx, attachments) => this.execute(session, line + args, attachments), } } diff --git a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts index c2143a35c3..6d57504375 100644 --- a/packages/client/ui-commands/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-commands/tests/browser-plugin.client.spec.ts @@ -7,7 +7,7 @@ * (HMR safety), and the service satisfies the frozen CommandUiContract. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -32,8 +32,11 @@ async function bench() { ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id), scopeOf: (c: Context) => scopeOf(c), + subagentAddress: (id: SessionId) => id === sid('child') + ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } + : undefined, }) - const commandsRemote = { list: () => Promise.resolve([]) } + const commandsRemote = { list: () => Promise.resolve({ ok: true as const, value: [] }) } // The service subscribes its cache-invalidation events on construction, so // the Remote face needs `$on` even where this spec dispatches none. ctx.provide('remote', { commands: commandsRemote, $on: () => () => {} }) @@ -73,6 +76,15 @@ describe('apply', () => { expect(slots.entries('conversation.input.overlay')).toHaveLength(0) }) + it('provides no File action without its composer owner', async () => { + const { fiber, sources } = await bench() + onTestFinished(() => fiber.dispose()) + const source = sources.get('/ command')! + const req = { query: '', position: 'leading' as const, drilled: false, signal: new AbortController().signal } + expect(await source.candidates({ sessionId: sid('s1') }, req)).toEqual([]) + expect(await source.candidates({ sessionId: sid('child') }, req)).toEqual([]) + }) + it('the overlay inject resolves the per-session popup controller by sessionId and fails loud on an unknown id', async () => { const { ctx, slots, mint } = await bench() const command = ctx.get('commandUi') as CommandUiRuntime diff --git a/packages/client/ui-commands/tests/directory.client.spec.ts b/packages/client/ui-commands/tests/directory.client.spec.ts index 29fc328fb2..85c0d55967 100644 --- a/packages/client/ui-commands/tests/directory.client.spec.ts +++ b/packages/client/ui-commands/tests/directory.client.spec.ts @@ -7,6 +7,7 @@ */ import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandDirectory } from '../src/client/directory.ts' @@ -52,6 +53,28 @@ function bench() { } describe('status and resolve (per key)', () => { + it('resolves bilingual aliases by definition identity and retains exact-name priority', async () => { + const goal = { + definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), + name: 'objective', + description: 'Reworded description.', + } + const exact = { name: 'goal', description: 'independent exact-name entry' } + const { dir, pull } = bench() + const refreshed = dir.refresh(S1) + pull(S1, 0).resolve([goal]) + await refreshed + expect(dir.resolve(S1, '目标')).toEqual(goal) + expect(dir.resolve(S1, 'goal')).toEqual(goal) + expect(dir.resolve(S1, '计划')).toBeUndefined() + expect(dir.resolve(S1, 'unregistered')).toBeUndefined() + const next = dir.refresh(S1) + pull(S1, 1).resolve([goal, exact]) + await next + expect(dir.resolve(S1, 'goal')).toEqual(exact) + expect(dir.resolve(S1, '目标')).toEqual(goal) + }) + it('starts cold and resolves nothing', () => { const { dir } = bench() expect(dir.status(S1)).toBe('cold') diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index fd48118587..8ec689efe3 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -8,15 +8,18 @@ * lifecycle, and the directory invalidation event subscriptions. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandResult } from '@deepseek-ai/dsh-commands/types' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { IconGoalOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' +import { en, zh, type CommandKey } from '../src/client/locales.ts' const sid = (k: string): SessionId => k as SessionId @@ -226,7 +229,7 @@ describe('candidates', () => { const { source, listCalls } = await bench() const names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) expect(listCalls).toEqual([{ sessionId: sid('s2') }]) - expect(names).toEqual(['plan', 'goal', 'attach']) + expect(names).toEqual(['goal', 'plan', 'attach']) }) it('hides leadingInput commands at inline position', async () => { @@ -240,7 +243,7 @@ describe('candidates', () => { const available = vi.fn((session: ClientSessionContext) => session.sessionId === sid('s1')) command.register(themeContribution({ available })) const s1Names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) - expect(s1Names).toEqual(['plan', 'goal', 'theme']) + expect(s1Names).toEqual(['goal', 'plan', 'theme']) expect(available).toHaveBeenLastCalledWith(proj('s1')) const s2Names = (await source.candidates(proj('s2'), req(''))).map(c => c.name) expect(s2Names).not.toContain('theme') @@ -256,7 +259,7 @@ describe('candidates', () => { it('localizes canonical built-in and contribution descriptions on every candidate request', async () => { let locale = 'zh' const commands: CommandDescriptor[] = [ - { name: 'compact', description: 'Compact older conversation history' }, + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' }, { name: 'goal', description: 'scoped goal override' }, { name: 'custom', description: 'plugin-authored copy' }, ] @@ -266,19 +269,21 @@ describe('candidates', () => { }) command.register(themeContribution({ description: () => `${locale}:theme` })) - await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ - { name: 'compact', description: 'zh:command:description.compact' }, - { name: 'goal', description: 'scoped goal override' }, - { name: 'custom', description: 'plugin-authored copy' }, - { name: 'theme', description: 'zh:theme' }, + // Rows read as [name, label, description]; the empty query orders by section. + const faces = async () => (await source.candidates(proj('s1'), req(''))).map(c => [c.name, c.label, c.description]) + await expect(faces()).resolves.toEqual([ + ['goal', undefined, 'scoped goal override'], + ['compact', 'zh:command:label.compact', 'zh:command:description.compact'], + ['custom', undefined, 'plugin-authored copy'], + ['theme', undefined, 'zh:theme'], ]) locale = 'en' - await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ - { name: 'compact', description: 'en:command:description.compact' }, - { name: 'goal', description: 'scoped goal override' }, - { name: 'custom', description: 'plugin-authored copy' }, - { name: 'theme', description: 'en:theme' }, + await expect(faces()).resolves.toEqual([ + ['goal', undefined, 'scoped goal override'], + ['compact', 'en:command:label.compact', 'en:command:description.compact'], + ['custom', undefined, 'plugin-authored copy'], + ['theme', undefined, 'en:theme'], ]) }) @@ -288,6 +293,157 @@ describe('candidates', () => { await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('collides with a host command') }) + describe('menu presentation (design doc for #3567)', () => { + /** First-party definitions plus an unrelated command, in Host registration order. */ + const SHIPPED: CommandDescriptor[] = [ + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history' }, + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-session-log-export'), name: 'export', description: 'Download this Session log as a ZIP archive' }, + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'), name: 'feedback', description: 'Record feedback about this session', input: { hint: '' } }, + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), name: 'goal', description: 'Set or view the goal for a long-running task', input: { hint: '', attachments: true } }, + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, + { definitionId: CommandDefinitionId('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, + { name: 'deploy', description: 'third-party command' }, + ] + const Glyph = () => null + const fileContribution = (run = vi.fn()): CommandContribution => ({ + name: 'file', + label: () => 'command:label.file', + icon: Glyph, + available: () => true, + ui: { kind: 'action', run }, + }) + const modelContribution = (): CommandContribution => ({ + name: 'model', + label: () => '模型', + description: () => '选择本会话使用的模型', + icon: Glyph, + available: () => true, + ui: themeUi(), + }) + + it('an empty query lists Add then Commands in usage order; built-in rows carry their localized face, other rows their own text', async () => { + const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + command.register(modelContribution()) + command.register(fileContribution()) + const rows = await source.candidates(proj('s1'), req('')) + expect(rows.map(row => row.name)).toEqual([ + 'file', 'goal', 'plan', 'feedback', 'compact', 'permission', 'model', 'export', 'deploy', + ]) + expect(rows.map(row => row.section)).toEqual([ + ...Array(4).fill('command:section.add'), + ...Array(5).fill('command:section.commands'), + ]) + expect(rows[1]).toEqual({ + name: 'goal', + label: 'command:label.goal', + description: 'command:description.goal', + icon: IconGoalOutline16, + hint: '', + section: 'command:section.add', + }) + expect(rows[0]).toEqual({ name: 'file', label: 'command:label.file', icon: Glyph, section: 'command:section.add' }) + expect(rows[6]).toMatchObject({ name: 'model', label: '模型', description: '选择本会话使用的模型', icon: Glyph }) + // A third-party command keeps its catalog text and gets no glyph. + expect(rows[8]).toEqual({ name: 'deploy', description: 'third-party command', section: 'command:section.commands' }) + }) + + it('a same-name override keeps its own presentation even when it copies the first-party description', async () => { + const commands: CommandDescriptor[] = [{ name: 'goal', description: en['description.goal'], input: { hint: 'x' } }] + const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) + const [row] = await source.candidates(proj('s1'), req('')) + expect(row).toEqual({ name: 'goal', description: en['description.goal'], hint: 'x', section: 'command:section.add' }) + expect(source.matchSpace!(proj('s1'), '/目标')).toBeUndefined() + expect(await source.matchEnter!(proj('s1'), '/目标 x', new AbortController().signal, { attachments: 0 })).toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/goal')).toHaveProperty('claim.name', 'goal') + }) + + it.each([['en', en], ['zh', zh]] as const)('description edits preserve menu claims and bilingual parsing under %s', async (_locale, dictionary) => { + const commands = SHIPPED.map(command => ({ ...command, description: command.description + '.' })) + const { fiber, source, warm } = await bench({ + commands: () => Promise.resolve({ commands }), + translate: (_namespace, key) => dictionary[key as CommandKey], + }) + onTestFinished(() => fiber.dispose()) + await warm(proj('s1')) + const rows = await source.candidates(proj('s1'), req('')) + for (const name of ['goal', 'plan', 'feedback'] as const) { + expect(rows.find(row => row.name === name)?.label).toBe(dictionary[`label.${name}`]) + const picked = menuPick(source, name, proj('s1')) + expect(picked).toHaveProperty('claim.token', `/${dictionary[`token.${name}`]} `) + for (const spelling of [en[`token.${name}`], zh[`token.${name}`]]) { + expect(source.matchSpace!(proj('s1'), `/${spelling}`)).toHaveProperty('claim.name', name) + expect(await source.matchEnter!(proj('s1'), `/${spelling} text`, new AbortController().signal, { attachments: 0 })) + .toHaveProperty('claim.token', `/${spelling} `) + } + } + }) + + it('a typed query ranks rows flat by name or label and drops the section headings', async () => { + const { command, source } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + command.register(modelContribution()) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + await expect(names('模型')).resolves.toEqual(['model']) + await expect(names('label.goal')).resolves.toEqual(['goal']) + await expect(names('ex')).resolves.toEqual(['export']) + // Prefix hits lead; the empty-query section order no longer applies. + await expect(names('pl')).resolves.toEqual(['plan', 'deploy']) + const rows = await source.candidates(proj('s1'), req('pl')) + expect(rows.every(row => row.section === undefined)).toBe(true) + }) + + it('an action contribution: the menu pick consumes the span and runs it; bare enter runs it even with attachments; an argued line misses', async () => { + const run = vi.fn() + const { command, source, mint, warm } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + command.register(fileContribution(run)) + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { consumes.push(r); return true }) + await warm(proj('s1')) + expect(menuPick(source, 'file', proj('s1'))).toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) + expect(run).toHaveBeenCalledExactlyOnceWith(proj('s1')) + expect(await source.matchEnter!(proj('s1'), '/file', new AbortController().signal, { attachments: 2 })).toBe('handled') + expect(consumes[1]).toEqual({ guard: { kind: 'bare-token', token: '/file' } }) + expect(run).toHaveBeenCalledTimes(2) + expect(await source.matchEnter!(proj('s1'), '/file now', new AbortController().signal, { attachments: 0 })).toBeUndefined() + expect(source.matchSpace!(proj('s1'), '/file')).toBeUndefined() + }) + + it('a built-in claim shows the localized token and submits the catalog name', async () => { + const { source, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + await warm(proj('s1')) + const outcome = menuPick(source, 'plan', proj('s1')) + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected the claim') + expect(outcome.claim.token).toBe('/command:token.plan ') + await outcome.claim.submit('do x', new Context(), []) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan do x', images: [] }]) + }) + + it('a typed localized token resolves to the built-in command on space and enter; the Host line carries the catalog name', async () => { + const { source, mint, warm, executeCalls } = await bench({ commands: () => Promise.resolve({ commands: SHIPPED }) }) + mint('s1') + await warm(proj('s1')) + const space = source.matchSpace!(proj('s1'), '/计划') + if (space === undefined || space === 'handled' || !('claim' in space)) throw new Error('expected the plan claim') + expect(space.claim.hint).toBe('[off|message]') + // The claim keeps the typed spelling (the draft carries it and the + // arguments are read after it); the submission sends the catalog name. + expect(space.claim.token).toBe('/计划 ') + expect(space.claim.name).toBe('plan') + const enter = await source.matchEnter!(proj('s1'), '/目标 ship it', new AbortController().signal, { attachments: 0 }) + if (enter === undefined || enter === 'handled' || !('claim' in enter)) throw new Error('expected the goal claim') + expect(enter.claim.attachments).toBe(true) + expect(enter.claim.token).toBe('/目标 ') + await enter.claim.submit('ship it', new Context(), []) + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/goal ship it', images: [] }]) + const typed = await source.matchEnter!(proj('s1'), '/plan now', new AbortController().signal, { attachments: 0 }) + if (typed === undefined || typed === 'handled' || !('claim' in typed)) throw new Error('expected the plan claim') + expect(typed.claim.token).toBe('/plan ') + executeCalls.length = 0 + expect(await source.matchEnter!(proj('s1'), '/压缩', new AbortController().signal, { attachments: 0 })).toBe('handled') + await vi.waitFor(() => { expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/compact', images: [] }]) }) + }) + }) }) describe('decorations (bare-invocation UI on host commands)', () => { @@ -302,7 +458,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source } = await bench() command.decorate(goalDecoration()) const names = (await source.candidates(proj('s1'), req(''))).map(c => c.name) - expect(names).toEqual(['plan', 'goal']) + expect(names).toEqual(['goal', 'plan']) }) it('bare enter opens the popup; an argued line never consults the decoration (host claim)', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b08f20181b..71555a9632 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 6471419852b7fdd09539f5dbd669f1d24f06460e -README.zh.md: c6fabbc1bb85ee5c6f2c6ca559feda59ef88aac2 +README.md: 8d68dfdb61943c7c45b38d9244134edecc86a593 +README.zh.md: b1d548ba8fc9276a6864548802a45796e74c0c7a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 6471419852..8d68dfdb61 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,6 +36,10 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props +The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. + +Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty. + Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. @@ -50,7 +54,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. -Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no File row, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c6fabbc1bb..b1d548ba8f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,6 +36,10 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props +输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 + +已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 + 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册 strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 @@ -50,7 +54,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供「文件」菜单项、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 64cc9baef7..060d2a0611 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 7eba82cfc0..95e6c5f3bc 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -92,6 +93,17 @@ interface WorkspaceNavigation { ): Promise } +/** Action registration used by the composer without importing its command-UI consumer. */ +interface FileCommandRegistry { + register(contribution: { + name: string + label(): string + icon: typeof IconPaperclipOutline16 + available(session: { sessionId: SessionId }): boolean + ui: { kind: 'action'; run(session: { sessionId: SessionId }): void } + }): () => void +} + /** Resolve the session-scoped Conversation action face, failing loud. */ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { const scoped = sessions.scope(id) @@ -197,6 +209,17 @@ export function apply(ctx: Context, config: Config = Config({})): void { const inputHub = new InputHub(ctx, t) const composerBlocks = new ComposerBlockRegistry() + ctx.inject(['commandUi'], (scope) => { + const commands = scope.get('commandUi') as FileCommandRegistry + scope.effect(() => commands.register({ + name: 'file', + label: () => t('input.file'), + icon: IconPaperclipOutline16, + available: session => inputHub.canPickFiles(session.sessionId), + ui: { kind: 'action', run: (session) => { inputHub.pickFiles(session.sessionId) } }, + }), 'ui-conversation: File action') + }) + // Conversation assembly and input share the Session binding lifecycle. The // source roster is installed before any consuming Slot entry. ctx.uiSession.provide({ diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index a2da5ad4c2..e70136736e 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -43,6 +43,9 @@ export interface SubmitOutcome { /** Command-mode credential supplied by one input-trigger source. */ export interface CommandClaim { + /** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */ + readonly name: string + /** Inserted command text with its argument separator; the bare complete name also retains the claim. */ readonly token: string readonly hint?: string readonly attachments?: boolean @@ -288,6 +291,12 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ dismissPopup(): void + /** + * Bind the mounted composer's file action and live intake availability. + * @param picker - availability query and native file-dialog opener. + * @returns the unbind disposer. + */ + bindFilePicker(picker: { available(): boolean; open(): void }): () => void } /** One independently addressable row projected from the transient queue snapshot. */ @@ -341,7 +350,7 @@ export interface InputState { readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean } + readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean } /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Read-only transient inbox projection from Session control, including pending steering. */ diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts index 09ef7b6727..7f2bec56ee 100644 --- a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -34,8 +34,9 @@ export function registerClaimDecoration(editor: LexicalEditor, activeToken: () = if (node.getStyle() === TOKEN_STYLE) node.setStyle('') return } - const token = activeToken() const text = node.getTextContent() + const active = activeToken() + const token = text === active?.trimEnd() ? text : active if (token === null || !text.startsWith(token)) { if (node.getStyle() === TOKEN_STYLE) node.setStyle('') return diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 492aba62df..f11592ba5a 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -11,6 +11,8 @@ * keydown AFTER compositionend, so a root-element composition watch holds the * guard for 10ms more (the old textarea's proven window); keyCode * 229 is the legacy signal engines emit without isComposing. + * The root's composition attribute suppresses placeholders until both the + * native composition and the editor's final text reconciliation finish. */ import type { LexicalEditor } from 'lexical' import { @@ -57,12 +59,20 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer // root element and re-arms on root swaps. let composing = false let composingUntil = 0 + let rootElement: HTMLElement | null = null + const syncComposition = (): void => { + rootElement?.toggleAttribute('data-composer-composing', composing || editor.isComposing()) + } const onCompositionStart = (): void => { composing = true + syncComposition() } const onCompositionEnd = (): void => { composing = false composingUntil = Date.now() + 10 + // The native event can precede the committed draft, including an empty + // cancellation. The callback also runs when no document text changed. + editor.update(() => {}, { onUpdate: syncComposition }) } const recentlyComposing = (): boolean => composing || Date.now() < composingUntil @@ -79,9 +89,15 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer editor.registerRootListener((root, prevRoot) => { prevRoot?.removeEventListener('compositionstart', onCompositionStart) prevRoot?.removeEventListener('compositionend', onCompositionEnd) + prevRoot?.removeAttribute('data-composer-composing') + composing = false + composingUntil = 0 + rootElement = root root?.addEventListener('compositionstart', onCompositionStart) root?.addEventListener('compositionend', onCompositionEnd) + syncComposition() }), + editor.registerUpdateListener(syncComposition), editor.registerCommand(KEY_ARROW_UP_COMMAND, arrow('up'), COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ARROW_DOWN_COMMAND, arrow('down'), COMMAND_PRIORITY_CRITICAL), // Tab acts only when the trigger menu has a highlighted completion; diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts index 9ae6b943d8..9a0a0479c0 100644 --- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -88,7 +88,7 @@ export function registerTextRefDecoration( const getMatch = (text: string): { start: number; end: number } | null => { const claim = activeToken() for (const range of scanTextRefs(text, lexiconOf())) { - if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim) continue + if (claim !== null && range.start === 0 && text.slice(range.start, range.end) === claim.trimEnd()) continue return { start: range.start, end: range.end } } return null diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 11bf37e5e8..80509a6ac7 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -21,7 +21,7 @@ import { registerPlainText } from '@lexical/plain-text' import { createEmptyHistoryState, registerHistory } from '@lexical/history' import { mergeRegister } from '@lexical/utils' import type { - ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, + ArbitrateKey, ArbitrateOutcome, CommandClaim, ComposerKeyboard, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, TokenSpan, @@ -155,6 +155,8 @@ export class SessionInputShell implements SessionInput { private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined + /** The mounted composer's file-picker opener (scoped pick-files event target). */ + private filePicker: Parameters[0] | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined /** Default sends retained until admission settles or scope disposal releases their attachments. */ @@ -610,6 +612,36 @@ export class SessionInputShell implements SessionInput { } } + /** + * Bind the mounted composer's file action and live intake availability. + * @param picker - availability query and native file-dialog opener. + * @returns the unbind disposer. + */ + bindFilePicker(picker: Parameters[0]): () => void { + this.filePicker = picker + return () => { + if (this.filePicker === picker) this.filePicker = undefined + } + } + + /** + * Read the mounted composer's live file-intake availability. + * @returns false when no accepting composer is mounted. + */ + canPickFiles(): boolean { + return this.filePicker?.available() === true + } + + /** + * Open the native file dialog when the mounted composer accepts files. + * @returns whether the opener was called. + */ + pickFiles(): boolean { + if (this.filePicker === undefined || !this.filePicker.available()) return false + this.filePicker.open() + return true + } + // ---- effect executor ---- /** The claim token the decoration transform styles; null while unclaimed. */ diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index f255877ddb..3a862753e1 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -158,6 +158,23 @@ export class InputHub implements SessionInputResolver { return this.shell(id) } + /** + * Query file intake without creating a Session input. + * @param id - target Session. + * @returns whether its mounted composer currently accepts files. + */ + canPickFiles(id: SessionId): boolean { + return this.shells.get(id)?.canPickFiles() === true + } + + /** + * Open the target composer's file dialog under its live intake policy. + * @param id - target Session. + */ + pickFiles(id: SessionId): void { + this.shells.get(id)?.pickFiles() + } + /** * Resolve the optional slash controller for composer chrome that launches * the shared candidate menu without typing a trigger. diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 899da6f996..7d1e503777 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -27,6 +27,11 @@ function argsAfter(draft: string, token: string): string { return '' } +/** A claimed name may stand alone; arguments require the token's separator. */ +function retainsClaim(draft: string, token: string): boolean { + return draft.startsWith(token) || draft === token.trimEnd() +} + /** The submit-plane slice of the published InputState. */ export interface SubmitSnapshot { readonly phase: InputState['phase'] @@ -53,6 +58,7 @@ export class SubmitMachine { ...(c ? { claim: { + name: c.name, token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}), ...(c.attachments === true ? { attachments: true } : {}), @@ -82,9 +88,9 @@ export class SubmitMachine { } } - /** Claimed integrity watch: a draft that breaks the token prefix releases the claim. */ + /** The complete command name retains its claim with or without the argument separator. */ private onDraftChanged(draft: string): readonly InputEffect[] { - if (this.phase === 'claimed' && this.claim !== undefined && !draft.startsWith(this.claim.token)) { + if (this.phase === 'claimed' && this.claim !== undefined && !retainsClaim(draft, this.claim.token)) { this.phase = 'plain' this.claim = undefined } @@ -199,7 +205,7 @@ export class SubmitMachine { } const text = ev.message ?? ev.outcome?.text if (ev.draft === flight.attempt.draftSnapshot - && this.claim !== undefined && ev.draft.startsWith(this.claim.token)) { + && this.claim !== undefined && retainsClaim(ev.draft, this.claim.token)) { this.phase = 'claimed' return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 4aeb755983..56da231c15 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -19,7 +19,8 @@ export const zh = { 'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', - 'input.commands': '指令', + 'input.commands': '添加文件或调用指令', + 'input.file': '文件', 'input.stop': '停止生成', 'input.send': '发送消息', 'input.send.queue': '排队发送', @@ -49,7 +50,6 @@ export const zh = { 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', - 'file.attach': '添加附件', 'file.pending': '待发送文件', 'file.remove': '移除文件 {name}', 'file.uploading': '上传中…', @@ -185,7 +185,8 @@ export const en = { 'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', - 'input.commands': 'Commands', + 'input.commands': 'Add files or run commands', + 'input.file': 'File', 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.send.queue': 'Queue message', @@ -215,7 +216,6 @@ export const en = { 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', - 'file.attach': 'Add attachment', 'file.pending': 'Pending files', 'file.remove': 'Remove file {name}', 'file.uploading': 'Uploading…', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index abd5911c90..6369e5728d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -195,6 +195,14 @@ color: var(--dsw-alias-label-caption); } +.input[data-composer-composing] p:last-child::after { + content: none; +} + +.input[data-composer-composing] + .placeholder { + visibility: hidden; +} + /* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. Insets mirror .input's pads (the other right 4px sits on .scroll). One ellipsized line: a wrapped second line would clip against the docked diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5e2e906482..6b7d83f3f6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -266,13 +266,21 @@ export const InputBar = memo(function InputBar({ // registration survives re-renders without re-arming per keystroke. const gate = useRef({ locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, + intakeFiles, uploadsPending, showToast, t, canAcceptDrop, }) gate.current = { locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, - intakeFiles, uploadsPending, showToast, t, + intakeFiles, uploadsPending, showToast, t, canAcceptDrop, } + useEffect(() => { + if (keyboard === undefined) return + return keyboard.bindFilePicker({ + available: () => gate.current.canAcceptDrop && fileInputRef.current !== null, + open: () => { fileInputRef.current?.click() }, + }) + }, [keyboard]) + useEffect(() => { if (editor === null || keyboard === undefined) return return registerComposerKeymap(editor, { @@ -382,8 +390,7 @@ export const InputBar = memo(function InputBar({ : null const hint = ((): string | null => { if (rawHint === null) return null - // Claim tokens have the `/name ` format (trailing space); trim to the bare name. - const commandName = input?.claim?.token.slice(1).trim() ?? '' + const commandName = input?.claim?.name ?? '' const hintKey = `hint.${commandName === 'goal' && hasGoal ? 'goal.active' : commandName}` // Dynamic lookup by claimed command name: unknown commands miss the // dictionary and keep the machine's own hint, so the call is wide. @@ -493,18 +500,6 @@ export const InputBar = memo(function InputBar({ - - - ([ ['read-only', ( - + )], @@ -34,7 +35,7 @@ const permissionGlyphs = new Map([ )], [FULL_ACCESS, ( - + diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index c6d6226bfe..a35bf956d0 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -113,6 +114,50 @@ async function bench() { } describe('Conversation inject API', () => { + it('owns the File action, reads its mounted composer availability, and unregisters on disposal', async () => { + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const contributions = new Map() + const registry = { + register: (contribution: CommandContribution) => { + contributions.set(contribution.name, contribution) + return () => { contributions.delete(contribution.name) } + }, + } satisfies Pick + b.runtime.ctx.provide('commandUi', registry) + await vi.waitFor(() => { expect(contributions.has('file')).toBe(true) }) + const file = contributions.get('file')! + const target = { sessionId: ROOT } + expect(file.label!()).toBe('文件') + expect(file.available(target)).toBe(false) + expect(file.available({ sessionId: 'missing' as SessionId })).toBe(false) + if (file.ui.kind !== 'action') throw new Error('File must be an action') + file.ui.run({ sessionId: 'missing' as SessionId }) + const keyboard = b.composerApi(ROOT).keyboard! + const open = vi.fn() + let available = true + const unbind = keyboard.bindFilePicker({ open, available: () => available }) + expect(file.available(target)).toBe(true) + file.ui.run(target) + expect(open).toHaveBeenCalledOnce() + available = false + expect(file.available(target)).toBe(false) + file.ui.run(target) + expect(open).toHaveBeenCalledOnce() + const replacement = vi.fn() + const removeReplacement = keyboard.bindFilePicker({ open: replacement, available: () => true }) + unbind() + expect(file.available(target)).toBe(true) + file.ui.run(target) + expect(replacement).toHaveBeenCalledOnce() + removeReplacement() + expect(file.available(target)).toBe(false) + file.ui.run(target) + expect(replacement).toHaveBeenCalledOnce() + await b.feature.dispose() + expect(contributions.size).toBe(0) + }) + it('assembles the target-neutral read face without Session side effects', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index d2a3064a58..13266b1ca2 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -846,7 +846,7 @@ describe('running and lock semantics', () => { act(() => { claimed.shell.setDraft('/goal ') claimed.shell.beginCommand( - { token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) }, + { name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'success' }) }, { start: 0, end: 6, draftRev: claimed.shell.snapshot.draftRev }, ) }) @@ -935,7 +935,7 @@ describe('running and lock semantics', () => { }) it('running continuable subagent keeps Send beside an independent Stop', () => { - const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({ + const { button, interruptButton, textarea, sink, stop, view, slotCalls, shell } = bench({ running: true, draft: '后续消息', subagent: { @@ -950,9 +950,14 @@ describe('running and lock semantics', () => { expect(button.getAttribute('aria-label')).toBe('排队发送') expect(interruptButton).not.toBeNull() expect(textarea.getAttribute('aria-disabled')).not.toBe('true') - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(true) expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false) + const click = vi.spyOn(HTMLInputElement.prototype, 'click') + onTestFinished(() => { click.mockRestore() }) + expect(shell.canPickFiles()).toBe(false) + expect(shell.pickFiles()).toBe(false) + expect(click).not.toHaveBeenCalled() + click.mockRestore() fireEvent.click(button) expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) @@ -985,10 +990,41 @@ describe('running and lock semantics', () => { ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }], ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => { const added = vi.fn(() => null) - const { view, slotCalls } = bench({ ...projection, addFiles: added }) - expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false) + const { view, slotCalls, shell } = bench({ ...projection, addFiles: added }) expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(false) expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true) + // The menu's File row opens the hidden input through the bound picker. + const click = vi.spyOn(HTMLInputElement.prototype, 'click') + onTestFinished(() => { click.mockRestore() }) + expect(shell.canPickFiles()).toBe(true) + expect(shell.pickFiles()).toBe(true) + expect(click).toHaveBeenCalledOnce() + click.mockRestore() + }) + + it.each([ + ['removed session', { disabled: true }], + ['inert composer', { inert: true }], + ['blocked composer', { blocked: { reason: 'waiting' } }], + ])('%s hides and refuses the File action', (_name, state) => { + const { shell, view } = bench({ ...state, addFiles: () => null }) + const input = view.container.querySelector('input[type="file"]')! + const open = vi.spyOn(input, 'click') + expect(shell.canPickFiles()).toBe(false) + expect(shell.pickFiles()).toBe(false) + expect(open).not.toHaveBeenCalled() + }) + + it('file action availability follows mount and live composer state', () => { + const { props, view, shell } = bench({ addFiles: () => null }) + expect(shell.canPickFiles()).toBe(true) + view.rerender() + expect(shell.canPickFiles()).toBe(false) + view.rerender() + expect(shell.canPickFiles()).toBe(true) + view.unmount() + expect(shell.canPickFiles()).toBe(false) + expect(shell.pickFiles()).toBe(false) }) it('parent-offline running continuable locks Send but keeps independent Stop usable', () => { @@ -1006,7 +1042,7 @@ describe('running and lock semantics', () => { }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) expect(button.getAttribute('aria-label')).toBe('发送消息') expect(button.disabled).toBe(true) expect(interruptButton?.disabled).toBe(false) @@ -1058,7 +1094,7 @@ describe('running and lock semantics', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('会话不可用') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -1191,7 +1227,7 @@ describe('running and lock semantics', () => { expect(editableOf(textarea)).toBe(false) expect(textarea.getAttribute('aria-haspopup')).toBe('menu') expect(textarea.getAttribute('aria-expanded')).toBe('false') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(textarea) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -1236,6 +1272,7 @@ describe('machine pending lock', () => { shell.setDraft('/goal ') shell.beginCommand( { + name: 'goal', token: '/goal ', submit: () => new Promise(() => {}), // never settles: stays submitting }, @@ -1262,7 +1299,7 @@ describe('decorations', () => { act(() => { shell.setDraft('/goal ') shell.beginCommand( - { token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { name: 'goal', token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) }, { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, ) shell.editor.update(() => {}, { discrete: true }) // flush the queued decoration refresh @@ -1281,13 +1318,56 @@ describe('decorations', () => { act(() => { shell.setDraft('/goal ') shell.beginCommand( - { token: '/goal ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { name: 'goal', token: '/goal ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, { start: 0, end: 6, draftRev: shell.snapshot.draftRev }, ) }) expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) }) + it('the hint lookup keys on the claim name, so a localized claim token keeps the locale entry', () => { + const { shell, textarea } = bench() + act(() => { + shell.setDraft('/目标 ') + shell.beginCommand( + { name: 'goal', token: '/目标 ', hint: '[|clear|edit |pause|resume]', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { start: 0, end: 4, draftRev: shell.snapshot.draftRev }, + ) + }) + expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) + }) + + it('suppresses placeholders throughout native composition, including a temporarily empty draft', async () => { + const { shell, textarea, view } = bench() + expect(view.container.querySelector('[data-composer-placeholder]')).not.toBeNull() + fireEvent.compositionStart(textarea) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + act(() => { shell.setDraft('z') }) + act(() => { shell.setDraft('') }) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + fireEvent.compositionEnd(textarea, { data: '' }) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + await act(async () => {}) + expect(textarea.hasAttribute('data-composer-composing')).toBe(false) + + act(() => { + shell.setDraft('/目标 ') + shell.beginCommand( + { name: 'goal', token: '/目标 ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) }, + { start: 0, end: 4, draftRev: shell.snapshot.draftRev }, + ) + }) + fireEvent.compositionStart(textarea) + act(() => { shell.setDraft('/目标 z') }) + act(() => { shell.setDraft('/目标 ') }) + expect(textarea.hasAttribute('data-composer-composing')).toBe(true) + fireEvent.compositionEnd(textarea, { data: '这' }) + act(() => { shell.setDraft('/目标 这') }) + await act(async () => {}) + expect(textarea.hasAttribute('data-composer-composing')).toBe(false) + expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe('') + }) + it('an inserted reference renders a real chip capsule with its icon and label', () => { const { view, shell } = bench() const reference = { @@ -1494,7 +1574,7 @@ describe('strips and variants', () => { describe('command launcher chrome and control seats', () => { it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('指令')).toBeTruthy() + expect(view.getByLabelText('添加文件或调用指令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Every seat dispatched, nothing rendered (render passes may repeat; the @@ -1513,7 +1593,7 @@ describe('command launcher chrome and control seats', () => { const toggleCommandMenu = vi.fn() const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) }) - const launcher = view.getByLabelText('指令') + const launcher = view.getByLabelText('添加文件或调用指令') expect(launcher.getAttribute('aria-expanded')).toBe('false') fireEvent.click(launcher) expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) @@ -1683,7 +1763,7 @@ describe('command launcher chrome and control seats', () => { it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index de0e3aa196..b16470fdd8 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -111,7 +111,7 @@ function bench(over?: { shell.setDraft(token) shell.beginCommand( { - token, hint, + name: token.slice(1).trim(), token, hint, ...(attachments === true ? { attachments: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, @@ -141,7 +141,7 @@ describe('matrix row: claimed', () => { const { view, textarea, shell, claim } = bench() claim() act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' }) + expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标' }) expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ') // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior). expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) @@ -201,7 +201,7 @@ describe('matrix row: claimed with attachments', () => { const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) }) claim('/goal ', '目标', true) // The claim currency carries the acceptance flag the pre-gate reads. - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true }) + expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标', attachments: true }) act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) }) @@ -318,7 +318,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') - expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 4571ebce58..42bf54913c 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -28,6 +28,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' +import { $replaceDetectSpanWithText } from '../src/client/input/editor/span-map.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' @@ -58,6 +59,7 @@ function commandSource( ) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ + name: desc.name, token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), ...(desc.input?.attachments === true ? { attachments: true } : {}), @@ -305,6 +307,34 @@ describe('scenario: images ride an accepting command through the real pipeline', }) describe('scenario H: backspace breaks the token', () => { + it.each(['goal', '目标', 'plan', '计划', 'feedback', '反馈'])('keeps /%s claimed when its arguments and separator are deleted', async (name) => { + const { source } = commandSource([{ name, description: name, input: { hint: '目标内容' } }], + () => Promise.resolve({ kind: 'success' })) + const b = await scopedBench((triggers) => { triggers.registerSource(source) }) + b.type(`/${name}`) + fireEvent.keyDown(b.textarea, { key: ' ', keyCode: 32 }) + expect(b.shell.snapshot.phase).toBe('claimed') + b.type(`/${name} 这是目标`) + for (let i = 0; i < 5; i++) { + act(() => { + b.shell.editor.update(() => { + const end = b.shell.snapshot.draft.length + $replaceDetectSpanWithText({ start: end - 1, end }, '') + }, { discrete: true }) + }) + } + expect(b.shell.snapshot.draft).toBe(`/${name}`) + expect(b.shell.snapshot.phase).toBe('claimed') + expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name}`) + b.type(`/${name} `) + await act(async () => {}) + expect(b.shell.snapshot.phase).toBe('claimed') + expect(b.shell.snapshot.draft).toBe(`/${name} `) + expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name} `) + b.type(`/${name}x`) + expect(b.shell.snapshot.phase).toBe('plain') + }) + it('claim releases automatically; the enter after that goes through adjudication again', async () => { const b = await bench() b.type('/goal') diff --git a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx index ea577859ef..daca265a89 100644 --- a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx +++ b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx @@ -4,13 +4,46 @@ * contenteditable reach the registered composer commands (the jsdom lane's * gesture entry, below the full component bench). */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { fireEvent } from '@testing-library/react' import { createEditor } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' describe('keymap keydown routing', () => { + it('clears composition presentation on root swaps and unregisters pending callbacks', async () => { + const editor = createEditor({ namespace: 'composition-root', onError: (e) => { throw e } }) + const first = document.createElement('div') + const second = document.createElement('div') + document.body.append(first, second) + onTestFinished(() => { + editor.setRootElement(null) + first.remove() + second.remove() + }) + editor.setRootElement(first) + const unregister = registerComposerKeymap(editor, { + arbitrate: () => 'pass', space: () => false, dismissPopup: () => {}, + canSubmit: () => false, submit: () => {}, intakeFiles: () => {}, pasteText: () => {}, + }) + onTestFinished(unregister) + fireEvent.compositionStart(first) + expect(first.hasAttribute('data-composer-composing')).toBe(true) + editor.setRootElement(second) + expect(first.hasAttribute('data-composer-composing')).toBe(false) + expect(second.hasAttribute('data-composer-composing')).toBe(false) + fireEvent.compositionStart(first) + expect(first.hasAttribute('data-composer-composing')).toBe(false) + fireEvent.compositionStart(second) + expect(second.hasAttribute('data-composer-composing')).toBe(true) + fireEvent.compositionEnd(second, { data: '' }) + unregister() + await Promise.resolve() + expect(second.hasAttribute('data-composer-composing')).toBe(false) + fireEvent.compositionStart(second) + expect(second.hasAttribute('data-composer-composing')).toBe(false) + }) + it('routes Enter to the keymap submit handler', () => { const editor = createEditor({ namespace: 'keymap-routing', onError: (e) => { throw e } }) const root = document.createElement('div') diff --git a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx index 1fb06b9b25..92bfa3ce15 100644 --- a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx +++ b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx @@ -429,9 +429,9 @@ describe('claim precedence over text-ref entities', () => { const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)' const LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map([['/', ['plan']]]) - it('keeps a claimed lexicon-listed token plain and warn-styled until release', () => { + it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and warn-styled until release', (token) => { const editor = makeEditor() - let claim: string | null = '/plan' + let claim: string | null = token registerClaimDecoration(editor, () => claim) registerTextRefDecoration(editor, () => LEXICON, () => claim) editor.update(() => { @@ -449,7 +449,7 @@ describe('claim precedence over text-ref entities', () => { } }) // Claimed: the entity transform yields the seat, the claim transform styles it. - expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' }) + expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token }) // Released (the shell's refresh nudges the seat dirty): the entity captures it. claim = null editor.update(() => { @@ -458,11 +458,11 @@ describe('claim precedence over text-ref entities', () => { }, { discrete: true }) expect(leaf()).toEqual({ type: 'composer-text-ref', style: '', text: '/plan' }) // Re-claimed: the entity reverts to plain text and the warn style returns. - claim = '/plan' + claim = token editor.update(() => { const first = ($getRoot().getFirstChild() as ParagraphNode).getFirstChild() if ($isTextNode(first)) first.markDirty() }, { discrete: true }) - expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: '/plan' }) + expect(leaf()).toEqual({ type: 'text', style: TOKEN_STYLE, text: token }) }) }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 73d16d0235..575c568c97 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -13,6 +13,7 @@ import { scanTextRefs } from '../src/client/input/decorations.ts' function claimOf(name: string, hint?: string): CommandClaim { return { + name, token: `/${name} `, ...(hint !== undefined ? { hint } : {}), submit: async () => ({ kind: 'success' }), @@ -191,7 +192,7 @@ describe('submit-machine: claimed lifecycle', () => { expect(m.state.phase).toBe('submitting') }) - it('breaking startsWith(token) auto-releases back to plain', () => { + it('editing the command name releases back to plain', () => { const m = new SubmitMachine() m.dispatch({ type: 'claim', claim: claimOf('goal') }) m.dispatch({ type: 'draft-changed', draft: '/goal args fine' }) @@ -201,6 +202,33 @@ describe('submit-machine: claimed lifecycle', () => { expect(m.state.claim).toBeUndefined() }) + it.each([ + ['goal', '/goal '], ['goal', '/目标 '], ['plan', '/plan '], ['plan', '/计划 '], + ['feedback', '/feedback '], ['feedback', '/反馈 '], + ])('retains %s as %s without its separator and submits an empty argument', (name, token) => { + const m = new SubmitMachine() + m.dispatch({ type: 'claim', claim: { ...claimOf(name), token } }) + for (const draft of [token + '这是目标', token, token.trimEnd(), token, token.trimEnd()]) { + m.dispatch({ type: 'draft-changed', draft }) + expect(m.state.phase).toBe('claimed') + expect(m.state.claim?.name).toBe(name) + } + const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: token.trimEnd() }) + const begin = effectAt(fx, 0, 'begin-submit') + expect(begin.args).toBe('') + m.dispatch({ type: 'submit-settled', attempt: begin.attempt, ok: false, draft: token.trimEnd() }) + expect(m.state.phase).toBe('claimed') + expect(m.state.claim?.name).toBe(name) + }) + + it.each(['/目', '/目标x', '/目标/文件', '', '看看 /目标'])('releases a goal claim for %j', (draft) => { + const m = new SubmitMachine() + m.dispatch({ type: 'claim', claim: { ...claimOf('goal'), token: '/目标 ' } }) + m.dispatch({ type: 'draft-changed', draft }) + expect(m.state.phase).toBe('plain') + expect(m.state.claim).toBeUndefined() + }) + it('explicit release returns to plain when nothing is in flight', () => { const m = new SubmitMachine() m.dispatch({ type: 'claim', claim: claimOf('goal') }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 8f4ece3735..f359ce5e69 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: ac0e7a4508bdf2b3705491d85837e84f5911d727 -README.zh.md: f0ce8691051aae4fbf664048c2f6e322db47684a +README.md: 5ae62206bed36e611635511f0ad3472ea4e98107 +README.zh.md: 3d154d9c4239f119624c74e219980c0be3ab81c0 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ac0e7a4508..5ae62206be 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,13 +30,13 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a grid of at most two cards per row. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each card uses the shared `FileTypeIcon` and shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row -The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. +The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the row, and the action footer starts 20px below the last file section. The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling. ### Inline-code links diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index f0ce869105..3d154d9c42 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,13 +30,13 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为每行最多两张卡片的网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片使用共享的 `FileTypeIcon`,显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 -“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 +“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于该行下方 16px,操作页脚位于最后一个文件区块下方 20px。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用省略号截断,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。 ### 行内代码链接 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 6164f30324..676e3569fd 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index 5125edd381..c78725bdcb 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -1,27 +1,30 @@ /** Delivery cards use one full-width row or a two-column, four-card summary. */ -.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 16px; } +.root { --deliverable-fill: var(--dsw-static-neutral-50); --deliverable-hover: var(--dsw-static-neutral-100); container-type: inline-size; display: flex; flex-direction: column; gap: 16px; min-width: 0; margin-top: 4px; } +/* The turn-tail gap alone separates adjacent file sections. The default top + margin remains when deliveries are the first file section after prose. */ +.root[data-after-produced-files='true'] { margin-top: 0; } :global(body[data-ds-dark-theme]) .root { --deliverable-fill: var(--dsw-static-neutral-850); --deliverable-hover: var(--dsw-static-neutral-800); } .hostStatus { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); } -.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; min-width: 0; } +.presented { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; } .presented[data-single='true'] { grid-template-columns: minmax(0, 1fr); } -.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 72px; padding: 12px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } +.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 60px; padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; } .file:hover { background: var(--deliverable-hover); } .cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } .cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } -.fileIcon { position: relative; z-index: 2; flex: 0 0 auto; display: grid; place-items: center; width: 48px; height: 48px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } +.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--deliverable-fill); color: var(--dsw-alias-link); } .fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; } .details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; } -.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; line-height: 22px; } -.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 12px; font-weight: 400; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; } +.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } +.description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .previewHint { display: none; } .file:hover .secondaryText { display: none; } .file:hover .previewHint { display: inline; } -.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 32px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 12px; background: var(--dsw-alias-button-floating-fill); } +.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); } .menuAnchor { align-self: stretch; } .open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); } -.open { padding: 5px 10px; font-size: 14px; line-height: 22px; } -.chevron { padding: 5px 6px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } +.open { padding: 4px 8px; font-size: 12px; line-height: 18px; } +.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } .open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } .chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; } .menuActionIcon { display: block; width: 16px; height: 16px; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index dac421a577..6099cfdf72 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -58,7 +58,10 @@ export function Deliverables({ matched, openFile, t, sessionId, useSessions, ope }, [matched.presented.length, host, reloadPresentedHost]) return <> {matched.produced.length > 0 && } - {matched.presented.length > 0 &&
+ {matched.presented.length > 0 &&
0 || undefined} + > {host === 'error' &&
{t('presented.hostError')} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index ef848827e8..5466473999 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -51,7 +51,7 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, return
) diff --git a/packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json b/packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json new file mode 100644 index 0000000000..b38a0325cf --- /dev/null +++ b/packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json @@ -0,0 +1,71 @@ +{ + "version": 1, + "owner": "DeepSeek Harness product design", + "sources": [ + { + "name": "square-icons-export", + "kind": "internal-design-export", + "sha256": "9b90bec3e20d99f179126d0981da01bcffa14060a284a844a0073472919e230e", + "icons": [ + "angular", + "c", + "clojure", + "cmake", + "cpp", + "csharp", + "css", + "dart", + "docker", + "elixir", + "env", + "erlang", + "flutter", + "git", + "go", + "graphql", + "haskell", + "ini", + "java", + "javascript", + "json", + "kotlin", + "lua", + "makefile", + "node", + "objective-c", + "perl", + "php", + "powershell", + "protobuf", + "python", + "r", + "react", + "ruby", + "rust", + "scala", + "shell", + "solidity", + "sql", + "svelte", + "toml", + "typescript", + "vue", + "wasm", + "xml", + "yaml", + "zig" + ] + }, + { + "name": "swift-square-icon", + "kind": "internal-design-export", + "sha256": "2afa6d5db27f6f9da5a2011613410891ef904f55420187bb7cc88a350daaa0bf", + "icons": [ + "swift" + ] + } + ], + "excluded": [ + "markdown" + ] +} diff --git a/packages/client/ui-primitives/src/code-file-icon-artwork.ts b/packages/client/ui-primitives/src/code-file-icon-artwork.ts new file mode 100644 index 0000000000..21e05c861f --- /dev/null +++ b/packages/client/ui-primitives/src/code-file-icon-artwork.ts @@ -0,0 +1,60 @@ +/** + * Embedded square code-icon artwork. The adjacent manifest records the design + * export digests; the 48 keys match CODE_FILE_TYPES exactly. + */ +import type { CodeFileType } from './code-file-types.ts' + +/** Placeholder replaced with one React-instance id before SVG insertion. */ +export const CODE_FILE_ICON_ID_TOKEN = '__DSH_CODE_ICON_INSTANCE__' + +/** Validated inner SVG markup for the established detailed code categories. */ +export const CODE_FILE_ARTWORK = Object.freeze({ + 'angular': '', + 'c': '', + 'clojure': '', + 'cmake': '', + 'cpp': '', + 'csharp': '', + 'css': 'CSS', + 'dart': '', + 'docker': '', + 'elixir': '', + 'env': '.ENV', + 'erlang': '', + 'flutter': '', + 'git': '', + 'go': '', + 'graphql': '', + 'haskell': '', + 'ini': 'INI', + 'java': '', + 'javascript': '', + 'json': '', + 'kotlin': '', + 'lua': '', + 'makefile': '', + 'node': '', + 'objective-c': 'OC', + 'perl': '', + 'php': '', + 'powershell': '', + 'protobuf': '', + 'python': '', + 'r': '', + 'react': '', + 'ruby': '', + 'rust': '', + 'scala': '', + 'shell': '', + 'solidity': '', + 'sql': '', + 'svelte': '', + 'swift': '', + 'toml': '', + 'typescript': '', + 'vue': '', + 'wasm': '', + 'xml': '', + 'yaml': '\n\n\n\n\n', + 'zig': '', +}) satisfies Readonly> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index f5112b64f1..7f1a3de926 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1,7 +1,8 @@ /** - * ic_ds_* icon set for the dsh web UI. All glyphs render fill="currentColor" - * and take {size, className}. Batch A mirrors the deepsuite icon library - * (same figma source); batch B glyphs are harness-only figma extracts. + * ic_ds_* icon set for the dsh web UI. All glyphs ride currentColor (filled + * or stroked) and take {size, className}. Batch A mirrors the deepsuite icon + * library (same figma source); batch B glyphs are harness-only figma + * extracts; the product glyphs at the end are hand-authored. */ import type { IconProps } from './props.ts' @@ -936,3 +937,56 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => ( ) + +/** + * Plan row glyph of the composer menu (#3567): a document with three ruled + * lines and a pencil over its corner. Drawn on a 14 grid whose content spans + * 0.5..13.5, so at the menu's 16px render it fills the same extent as the + * 16-grid glyphs beside it. + */ +export const IconPlanOutline14 = ({ size = 14, className }: IconProps) => ( + + + + + + + + +) + +/** + * Compact row glyph of the composer menu: the composer's context-usage ring + * (ContextMeter) frozen at its resting look — a quiet track with one filled + * quarter arc. A restyle of the live ring revisits this copy. + */ +export const IconCompactOutline16 = ({ size = 16, className }: IconProps) => ( + + + + +) + +/** + * The permission shield contour on the 16 grid (design set 1556), stroked at + * {@link SHIELD_OUTLINE_STROKE}. The composer's permission selector composes + * its mode marks (check, pencil, exclamation) over this same path inside one + * svg, so the geometry lives here once. + */ +export const SHIELD_OUTLINE_PATH = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z' + +/** Stroke width of {@link SHIELD_OUTLINE_PATH}. */ +export const SHIELD_OUTLINE_STROKE = '1.31831' + +/** Permission row glyph of the composer menu: the shield contour alone, without a mode mark. */ +export const IconShieldOutline16 = ({ size = 16, className }: IconProps) => ( + + + +) diff --git a/packages/client/ui-primitives/src/rank-by-name.ts b/packages/client/ui-primitives/src/rank-by-name.ts index 7dc3b3a0cc..2f7bdd880f 100644 --- a/packages/client/ui-primitives/src/rank-by-name.ts +++ b/packages/client/ui-primitives/src/rank-by-name.ts @@ -1,8 +1,9 @@ /** * Shared ranking for `/` menu candidates: the query must be a - * case-insensitive ordered subsequence of the candidate name. Prefix hits - * rank first, then the strongest alignment score, then the source order of - * the input. Decision record: + * case-insensitive ordered subsequence of the candidate name or, when the + * candidate carries one, of its display label (a localized title). Prefix + * hits rank first, then the strongest alignment score over either key, then + * the source order of the input. Decision record: * .agents/notes/archived/feature/2026-08-04-web-slash-command-fuzzy-discovery.md */ @@ -59,19 +60,32 @@ function alignmentScore(name: string, query: string): number | undefined { /** * Rank named items by a menu query. - * @param items - candidates in source order (a host catalog, then client contributions). + * @param items - candidates in source order (a host catalog, then client + * contributions); an item's optional `label` is a second search key beside + * its name. * @param rawQuery - the text typed after the trigger, matched case-insensitively. * @returns the matching items: prefix hits first, then by alignment score, * then in source order. The input list itself for an empty query. */ -export function rankByName(items: readonly T[], rawQuery: string): readonly T[] { +export function rankByName( + items: readonly T[], + rawQuery: string, +): readonly T[] { const query = rawQuery.toLowerCase() if (query === '') return items const ranked: Ranked[] = [] items.forEach((item, index) => { - const name = item.name.toLowerCase() - const score = alignmentScore(name, query) - if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score }) + const keys = item.label === undefined ? [item.name] : [item.name, item.label] + let prefix = false + let score: number | undefined + for (const key of keys) { + const lower = key.toLowerCase() + const keyScore = alignmentScore(lower, query) + if (keyScore === undefined) continue + prefix ||= lower.startsWith(query) + score = score === undefined ? keyScore : Math.max(score, keyScore) + } + if (score !== undefined) ranked.push({ item, index, prefix, score }) }) ranked.sort((left, right) => Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) diff --git a/packages/client/ui-primitives/tests/atoms.client.spec.tsx b/packages/client/ui-primitives/tests/atoms.client.spec.tsx index 70649d0874..59178671a4 100644 --- a/packages/client/ui-primitives/tests/atoms.client.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.client.spec.tsx @@ -462,8 +462,7 @@ describe('ConnectionIndicator', () => { it('renders outage, attempt progress, and recovered states without a native tooltip', () => { const reconnect = vi.fn() const labels = { - disconnectedLabel: 'Disconnected', - reconnectLabel: 'Reconnect', + disconnectedLabel: 'Disconnected, retry', connectingLabel: 'Connecting', recoveredLabel: 'Connected', reconnectActionLabel: 'Disconnected, reconnect now', @@ -476,8 +475,7 @@ describe('ConnectionIndicator', () => { expect(container.firstChild).toBeNull() rerender() const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' }) - expect(indicator.textContent).toContain('Disconnected') - expect(indicator.textContent).toContain('Reconnect') + expect(indicator.textContent).toContain('Disconnected, retry') expect(indicator.hasAttribute('title')).toBe(false) expect(indicator.querySelector('svg')).toBeTruthy() fireEvent.click(indicator) @@ -491,4 +489,27 @@ describe('ConnectionIndicator', () => { expect(screen.queryByRole('button')).toBeNull() expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() }) + + it('fades out for the exit duration before unmounting', () => { + vi.useFakeTimers() + try { + const labels = { + disconnectedLabel: 'Disconnected, retry', + connectingLabel: 'Connecting', + recoveredLabel: 'Connected', + reconnectActionLabel: 'Disconnected, reconnect now', + restartActionLabel: 'Connecting, restart now', + onReconnect: vi.fn(), + } + const { container, rerender } = render( + , + ) + rerender() + expect(screen.getByRole('button', { name: 'Disconnected, reconnect now' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(150) }) + expect(container.firstChild).toBeNull() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx b/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx index f8b155e871..462774952d 100644 --- a/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx +++ b/packages/client/ui-primitives/tests/code-file-icon.client.spec.tsx @@ -1,9 +1,12 @@ // @vitest-environment jsdom +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { classifyFileType, FileTypeIcon, type CodeFileType, type FileTypeProjectContext, } from '@deepseek-ai/dsh-client-ui-primitives' +import { CODE_FILE_ARTWORK, CODE_FILE_ICON_ID_TOKEN } from '../src/code-file-icon-artwork.ts' import { CODE_FILE_TYPES } from '../src/code-file-types.ts' afterEach(cleanup) @@ -12,6 +15,10 @@ const flutter: FileTypeProjectContext = { files: { 'config/pubspec.yaml': 'name: demo\ndependencies:\n flutter: sdk' }, } +function normalizeCodeIconIds(html: string): string { + return html.replace(/dsh-code-icon-[A-Za-z0-9_-]+/gu, 'dsh-code-icon-instance') +} + describe('code-file classification', () => { it.each([ ['app.component.ts', 'angular'], @@ -80,7 +87,8 @@ describe('code-file classification', () => { const view = render() const explicit = render() - expect(view.container.innerHTML).toBe(explicit.container.innerHTML) + expect(normalizeCodeIconIds(view.container.innerHTML)) + .toBe(normalizeCodeIconIds(explicit.container.innerHTML)) }) it('keeps Markdown and SVG on the traditional file artwork', () => { @@ -90,6 +98,40 @@ describe('code-file classification', () => { }) describe('full-color code-file artwork', () => { + it('embeds exactly the established 48 code categories', () => { + expect(Object.keys(CODE_FILE_ARTWORK)).toEqual([...CODE_FILE_TYPES]) + expect(CODE_FILE_ARTWORK).not.toHaveProperty('markdown') + for (const [type, artwork] of Object.entries(CODE_FILE_ARTWORK)) { + expect(artwork).not.toMatch(/<(?:script|style|foreignObject)\b|\son[a-z]+\s*=/iu) + expect(artwork).not.toMatch(/(?:href|xlink:href)="(?!#)/iu) + const ids = [...artwork.matchAll(/\bid="([^"]+)"/gu)].map(match => match[1]!) + expect(new Set(ids).size, `${type} has duplicate SVG ids`).toBe(ids.length) + for (const id of ids) expect(id).toMatch(new RegExp(`^${CODE_FILE_ICON_ID_TOKEN}`)) + const references = [ + ...[...artwork.matchAll(/url\(#([^)]+)\)/gu)].map(match => match[1]!), + ...[...artwork.matchAll(/(?:href|xlink:href)="#([^"]+)"/gu)].map(match => match[1]!), + ] + for (const reference of references) { + expect(reference).toMatch(new RegExp(`^${CODE_FILE_ICON_ID_TOKEN}`)) + expect(ids).toContain(reference) + } + } + }) + + it('records the design exports that supply the closed artwork set', () => { + const path = resolve('packages/client/ui-primitives/src/code-file-icon-artwork.manifest.json') + const manifest = JSON.parse(readFileSync(path, 'utf8')) as { + owner: string + sources: Array<{ kind: string; sha256: string; icons: string[] }> + excluded: string[] + } + expect(manifest.owner).toBe('DeepSeek Harness product design') + expect(manifest.sources.every(source => source.kind === 'internal-design-export')).toBe(true) + expect(manifest.sources.every(source => /^[0-9a-f]{64}$/u.test(source.sha256))).toBe(true) + expect(manifest.sources.flatMap(source => source.icons).sort()).toEqual([...CODE_FILE_TYPES].sort()) + expect(manifest.excluded).toEqual(['markdown']) + }) + it.each(CODE_FILE_TYPES)('%s renders its supplied square SVG', (type) => { const { container } = render() const svg = container.querySelector('svg')! @@ -110,9 +152,16 @@ describe('full-color code-file artwork', () => { it('uses instance-safe gradient ids and forwards sizing classes', () => { const { container } = render(<>) - const ids = [...container.querySelectorAll('linearGradient')].map(gradient => gradient.id) - expect(new Set(ids).size).toBe(2) - expect([...container.querySelectorAll('rect')].map(rect => rect.getAttribute('fill'))).toEqual(ids.map(id => `url(#${id})`)) + const ids = [...container.querySelectorAll('[id]')].map(element => element.id) + expect(ids.length).toBeGreaterThan(2) + expect(new Set(ids).size).toBe(ids.length) + expect(container.innerHTML).not.toContain(CODE_FILE_ICON_ID_TOKEN) + const references = [...container.querySelectorAll('*')].flatMap(element => + [...element.attributes].flatMap(attribute => + [...attribute.value.matchAll(/url\(#([^)]+)\)/gu)].map(match => match[1])), + ) + expect(references.length).toBeGreaterThan(0) + for (const reference of references) expect(ids).toContain(reference) const sized = render() const svg = sized.container.querySelector('svg')! diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index 1b9a9c7773..d8b566704a 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -17,8 +17,16 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 21 figma extracts + eight product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(75) + it('exports the full icon set (46 deepsuite + 21 figma extracts + eleven product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(78) + // The composer menu's own glyphs, pinned by name. + expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16'])) + }) + + it('the permission selector composes its marks over the shield contour exported here', () => { + const { container } = render() + expect(container.querySelector('path')?.getAttribute('d')).toBe(primitives.SHIELD_OUTLINE_PATH) + expect(container.querySelector('path')?.getAttribute('stroke-width')).toBe(primitives.SHIELD_OUTLINE_STROKE) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts index 89ed5c7733..027fbb4958 100644 --- a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts +++ b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts @@ -34,6 +34,20 @@ describe('rankByName', () => { expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab']) }) + it('matches the display label as a second key and keeps the stronger alignment', () => { + const items = [ + { name: 'goal', label: '目标' }, + { name: 'plan', label: '计划' }, + { name: 'permission', label: '权限' }, + ] + expect(names(rankByName(items, '目标'))).toEqual(['goal']) + expect(names(rankByName(items, 'goal'))).toEqual(['goal']) + expect(names(rankByName(items, 'p'))).toEqual(['plan', 'permission']) + expect(names(rankByName(items, '划'))).toEqual(['plan']) + // A label prefix hit ranks like a name prefix hit. + expect(names(rankByName([{ name: 'xplan', label: 'plan' }, { name: 'plant' }], 'pla'))).toEqual(['xplan', 'plant']) + }) + it('returns the ranked items with their payload intact', () => { const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }] expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }]) diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 0b06befaff..f2cc3c40f2 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 4307db79ed..17edf34172 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 4aa8d3872f..b4717e39a2 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 7cc1dcc427..e7943cbdbf 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index cb4566a437..dfe7e55738 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: 845d9c48dce2264d478f0ac854ef85a480828a14 -README.zh.md: 814082f94156e44b45e015e67acb7a281ba2e7e9 +README.md: af68c42ad111f74e68037436e18a5be57195a64b +README.zh.md: f929df66a13cde7beb63721922f2a3f4aa90ba43 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 845d9c48dc..af68c42ad1 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -25,7 +25,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover ## Use this package -Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension. Automatic recovery shows **Reconnecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. +Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. In both the expanded sidebar and collapsed rail, the control exposes the localized Settings label as its accessible name. A pale-yellow **Disconnected** action beside Settings indicates browser offline suspension; its permanent retry glyph marks the retry action, which the Chinese outage copy also names (连接异常,刷新重试). Every recovery attempt shows a spinner beside **Reconnecting** with one to three dots advancing every 500ms, and an attempt stays visible for at least 800ms so brief retries do not flicker. Selecting either yellow state starts an immediate retry; press feedback stays within the warning palette. Recovery changes the region to pale-green **Connected** for two seconds from the moment the green pill becomes visible. The pill fades in on appearance, fades out over 150ms on removal, and sizes to its current label. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time. ### The General section @@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may ### Connection recovery -The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the two-second recovered-state timer. +The shell is an explicit recovery consumer, so it injects Connection directly rather than adding lifecycle controls to `ctx.remote`. Its private hooks compartment binds `ctx.connection.state`, while the component receives only the selected state and an injected callback for `ctx.connection.reconnect()`. `ConnectionIndicator` owns the inline presentation and receives all visible and accessible copy from the `settings` locale namespace; the shell owns the 800ms minimum-visible hold for the connecting state and the two-second recovered-state timer, which starts when the recovered pill becomes visible after the hold. ### Document availability diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 814082f941..f929df66a1 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;自动恢复期间显示**自动重连中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 +用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。在展开侧边栏和收起轨道中,该控件都会把本地化的 Settings 文案作为其可访问名称。Settings 右侧浅黄色的**连接异常**操作表示浏览器离线暂停;其常驻重试图形与中文文案「连接异常,刷新重试」都指明重试动作。每次恢复尝试都显示 spinner 加**重新连接中**,其后一至三个点每 500ms 前进一次,且每次尝试至少可见 800ms,短暂重试不会闪动。选中任一黄色状态都会立即发起重试;按压反馈留在黄色色阶内。恢复后该区域变为浅绿色的**连接成功**,从绿色药丸可见起驻留 2 秒再消失。药丸出现时淡入、移除时以 150ms 淡出,宽度随当前文案自适应。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。 ### 「通用」分区 @@ -55,7 +55,7 @@ kind: "package-reference" ### 连接恢复 -外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;2 秒恢复状态计时器归外壳所有。 +外壳是明确的恢复功能消费方,因此直接注入 Connection,而不把生命周期控制放进 `ctx.remote`。它的私有 hooks compartment 绑定 `ctx.connection.state`,组件只接收选出的状态与调用 `ctx.connection.reconnect()` 的注入回调。`ConnectionIndicator` 拥有内联展示并从 `settings` locale namespace 接收全部可见与无障碍文案;连接中状态的 800ms 最短可见驻留与 2 秒恢复确认计时器归外壳所有;恢复计时从驻留结束、恢复药丸实际可见时开始。 ### 文档可用性 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a4932dd534..f884624c13 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -49,22 +49,23 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@types/react": "~18.3.1", - "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "clsx": "^2.0.0" + "@types/react": "~18.3.1", + "clsx": "^2.0.0", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx index e0b20dceed..14a93af9bc 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.tsx @@ -23,6 +23,9 @@ import css from './SettingsRoot.module.css' const RECOVERY_CONFIRMATION_MS = 2_000 +/** Minimum visible time for the connecting pill; shorter attempts read as flicker. */ +const CONNECTING_MIN_VISIBLE_MS = 800 + /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return @@ -113,6 +116,8 @@ export function SettingsRoot(props: SettingsRootComponentProps) { const [activeId, setActiveId] = useState(undefined) const [completedOnboarding, setCompletedOnboarding] = useState>(() => new Set()) const [showRecovery, setShowRecovery] = useState(false) + const [holdConnecting, setHoldConnecting] = useState(false) + const connectingShownAt = useRef(undefined) const triggerButton = useRef(null) const wasOpen = useRef(open) const close = useCallback(() => { @@ -157,8 +162,32 @@ export function SettingsRoot(props: SettingsRootComponentProps) { } if (previous !== 'disconnected' && previous !== 'connecting') return setShowRecovery(true) + }, [connectionState]) + + // The confirmation window starts when the recovered pill becomes visible, + // which the connecting minimum-visible hold can delay past the transition. + useLayoutEffect(() => { + if (!showRecovery || holdConnecting) return const timeout = window.setTimeout(() => { setShowRecovery(false) }, RECOVERY_CONFIRMATION_MS) return () => { window.clearTimeout(timeout) } + }, [showRecovery, holdConnecting]) + + useLayoutEffect(() => { + if (connectionState === 'connecting') { + connectingShownAt.current = Date.now() + return + } + const shownAt = connectingShownAt.current + if (shownAt === undefined) return + connectingShownAt.current = undefined + const remaining = CONNECTING_MIN_VISIBLE_MS - (Date.now() - shownAt) + if (remaining <= 0) return + setHoldConnecting(true) + const timeout = window.setTimeout(() => { setHoldConnecting(false) }, remaining) + return () => { + window.clearTimeout(timeout) + setHoldConnecting(false) + } }, [connectionState]) const completeOnboardingStep = useCallback((id: string) => { @@ -169,10 +198,10 @@ export function SettingsRoot(props: SettingsRootComponentProps) { }, []) let connectionIndicator: ConnectionIndicatorState | undefined - if (connectionState === 'disconnected') { - connectionIndicator = 'disconnected' - } else if (connectionState === 'connecting') { + if (connectionState === 'connecting' || holdConnecting) { connectionIndicator = 'connecting' + } else if (connectionState === 'disconnected') { + connectionIndicator = 'disconnected' } else if (showRecovery) { connectionIndicator = 'recovered' } @@ -194,7 +223,6 @@ export function SettingsRoot(props: SettingsRootComponentProps) { /** The settings namespace key union. */ @@ -28,9 +27,8 @@ export const en = { 'openDocument.error': 'Could not open configuration file', 'general.nav': 'General', 'connection.error': 'Disconnected', - 'connection.retry': 'Reconnect now', 'connection.connecting': 'Reconnecting', 'connection.connected': 'Connected', 'connection.reconnect': 'Disconnected, reconnect now', - 'connection.restart': 'Reconnecting automatically, reconnect now', + 'connection.restart': 'Reconnecting, reconnect now', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index e2e3099489..c1f5428a30 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -1,20 +1,30 @@ -/** Ownerless-copy registrations: the five seats, dictionaries, thunked labels, and HMR recovery. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment jsdom +/** + * Ownerless-copy registrations inside the assembled web client: the five + * seats, the `settings` dictionaries, the locale-following nav label, the + * loopback-only document action over the real settings mirror, and recovery + * across Loader rebuilds of the declaring chain. + */ +import { describe, expect, onTestFinished, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' +import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '@deepseek-ai/dsh-client-locale/src/locale-settings.ts' +import { inject } from '../src/client/index.ts' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' -// These specs assert the shipped Chinese copy. The lane has no jsdom `window`, -// so browser-language detection never runs and a fresh LocaleRuntime opens on -// FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. +const SELF = '@deepseek-ai/dsh-client-ui-settings-general' +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const it = createClientTest({ roster: webApp }) +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 +/** Dictionary namespace this plugin owns; every seat it fills declares it. */ +const NS = 'settings' /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ @@ -25,56 +35,55 @@ const SEATS = [ ['settings.section', GeneralSection], ] as const -async function bench(isLoopback = true) { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - const locale = new LocaleRuntime(ctx) - locale.setLocale('zh') - ctx.provide('locale', locale) - const settingsDescribe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: true, - namespaces: [], - }, - })) - const settingsOpenDocument = vi.fn(() => Promise.resolve({ - ok: true as const, value: { opened: true as const }, - })) - const remote = new TestRemote(ctx, { - settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, - }) - // The fixed Host facts the shell reads its loopback-only action from. - remote.$host = { home: undefined, isLoopback } - ctx.provide('connection', { - state: { getSnapshot: () => 'connected', subscribe: () => () => {} }, - reconnect: () => {}, - } as never) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } +/** One Host view of the locale preference, including its revision fence. */ +function localeView(preference: string, revision = 0): SettingsNamespaceView { + return { + ns: LOCALE_SETTINGS_NAMESPACE, + // The Remote wire serializes nested Schema values before the client rehydrates them. + schema: JSON.parse(JSON.stringify(LocaleSettingsSchema.toJSON())) as SettingsNamespaceView['schema'], + value: { preference }, + applies: 'live', + secrets: [], + revision, + } } -/** Declare the shell's six child slots the way ui-settings' entry does. */ -function declare(slots: SlotRegistry): () => void { - return slots.register( - { - name: 'root', - children: { - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - } as never, - () => null, - ) +async function client(mock: RemoteMock, start: () => Promise, hasDocument = false) { + const settings = mock.remote.settings + settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')] })) + const c = await start() + // The locale adopts the Host preference once the describe mirror holds the document. + await c.ctx.settingsScope.describe().ensure() + return { c, settings } } -function generalEntry(slots: SlotRegistry) { - return slots.entries('settings.section').find(e => e.component === GeneralSection) +/** This plugin's rows in a seat: the list seats also carry feature-owned rows (the product's other sections and actions). */ +function ownEntries(c: TestClient, name: (typeof SEATS)[number][0]) { + return c.ctx.slots.entries(name).filter(entry => entry.locale === NS) +} + +function generalEntry(c: TestClient) { + return ownEntries(c, 'settings.section').find(entry => entry.component === GeneralSection)! +} + +function generalLabel(c: TestClient): string | undefined { + return resolveSlotLabel(generalEntry(c).options.label) +} + +function actionInjectedOf(c: TestClient): SettingsDocumentActionInjected { + const entry = ownEntries(c, 'settings.action')[0]! + return (entry.inject as unknown as () => SettingsDocumentActionInjected)() +} + +function expectSeated(c: TestClient): void { + for (const [name, component] of SEATS) { + expect(ownEntries(c, name).map(entry => entry.component)).toEqual([component]) + } +} + +/** The page authority the `connection` plugin classifies at apply, reconfigured through the jsdom instance vitest exposes. */ +function setPageUrl(url: string): void { + (globalThis as unknown as { jsdom: { reconfigure(settings: { url: string }): void } }).jsdom.reconfigure({ url }) } describe('ui-settings-general apply', () => { @@ -82,138 +91,144 @@ describe('ui-settings-general apply', () => { expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) - it('fills all five seats for declarations before or after apply', async () => { - const before = await bench() - declare(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - for (const [name, component] of SEATS) { - expect(before.slots.entries(name)[0]!.component).toBe(component) - } - const entry = generalEntry(before.slots)! + it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => { + const { c } = await client(mock, start) + expect(c.ctx.locale.getSnapshot().active).toBe('zh') + expectSeated(c) + const entry = generalEntry(c) expect(entry.options).toMatchObject({ id: 'general', order: 0 }) // The nav label is a locale-following thunk; owners resolve at read time. - expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') - expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - expect(before.slots.entries('settings.general.item')).toEqual([]) - // The onboarding hole stays declared for feature-owned steps; this plugin - // no longer seats one. - expect(before.slots.entries('settings.onboarding')).toEqual([]) - const action = before.slots.entries('settings.action')[0]! - const actionInjected = (action.inject as unknown as () => SettingsDocumentActionInjected)() - expect(actionInjected.controller.store.getSnapshot().status).toBe('idle') - expect(actionInjected.hooks.snapshot).toBe(actionInjected.controller.store) - // Copy rides the standard locale seat: every seat declares the namespace. - for (const [name] of SEATS) { - expect(before.slots.entries(name)[0]!.locale).toBe('settings') - } - const after = await bench() - await after.ctx.plugin({ inject: [...inject], apply }).await() - for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) - declare(after.slots) - await Promise.resolve() + expect(generalLabel(c)).toBe('通用设置') + expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + // The General items and the onboarding steps are feature-owned rows; this plugin seats none of its own. + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) + expect(c.ctx.slots.entries('settings.onboarding').filter(row => row.locale === NS)).toEqual([]) + const { controller, hooks } = actionInjectedOf(c) + expect(controller.store.getSnapshot().status).toBe('idle') + expect(hooks.snapshot).toBe(controller.store) + // Copy rides the standard locale seat: every row this plugin seats declares the namespace. for (const [name, component] of SEATS) { - expect(after.slots.entries(name)[0]!.component).toBe(component) - // The self-inflicted ledger notifications hit the duplicate guard. - expect(after.slots.entries(name)).toHaveLength(1) + expect(c.ctx.slots.entries(name).find(row => row.component === component)!.locale).toBe(NS) } + }, COLD_BOOT_TIMEOUT_MS) + + it('registers the zh/en settings dictionaries and frees the seats when its row unloads', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const english = localeView('en', 1) + settings.mutate.mockResolvedValueOnce(ok(english)) + const t = c.ctx.locale.bind(NS) + expect(t('title')).toBe('设置') + expect(t('connection.error')).toBe('连接异常,刷新重试') + expect(t('connection.connecting')).toBe('重新连接中') + expect(t('connection.connected')).toBe('连接成功') + c.ctx.locale.setLocale('en') + expect(t('close')).toBe('Close') + expect(t('connection.reconnect')).toBe('Disconnected, reconnect now') + expect(t('connection.connecting')).toBe('Reconnecting') await vi.waitFor(() => { - expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english]) }) - }) - - it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.locale.bind('settings')('title')).toBe('设置') - expect(b.locale.bind('settings')('connection.error')).toBe('连接异常') - expect(b.locale.bind('settings')('connection.connecting')).toBe('自动重连中') - expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功') - b.locale.setLocale('en') - expect(b.locale.bind('settings')('close')).toBe('Close') - expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now') - expect(b.locale.bind('settings')('connection.connecting')).toBe('Reconnecting') - b.locale.setLocale('zh') - await fiber.dispose() + await c.unload(SELF) + await c.flush() // The (ns, locale) seats are free again — the dictionary disposer ran. - expect(() => b.locale.register('settings', 'zh', {})).not.toThrow() - expect(() => b.locale.register('settings', 'en', {})).not.toThrow() + expect(() => { c.ctx.locale.register(NS, 'zh', {})() }).not.toThrow() + expect(() => { c.ctx.locale.register(NS, 'en', {})() }).not.toThrow() }) - it('the nav label thunk follows the active locale without re-registration', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const zhVersions = SEATS.map(([name]) => b.slots.getVersion(name)) - b.locale.setLocale('en') + it('the nav label thunk follows the active locale without re-registration', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const english = localeView('en', 1) + const chinese = localeView('zh', 2) + settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese)) + const zhVersions = SEATS.map(([name]) => c.ctx.slots.getVersion(name)) + c.ctx.locale.setLocale('en') // No ledger churn: freshness rides the thunk (and the renderer's locale // subscription), not re-registration. SEATS.forEach(([name], i) => { - expect(b.slots.getVersion(name)).toBe(zhVersions[i]!) - expect(b.slots.entries(name)).toHaveLength(1) + expect(c.ctx.slots.getVersion(name)).toBe(zhVersions[i]!) + expect(ownEntries(c, name)).toHaveLength(1) + }) + expect(generalLabel(c)).toBe('General') + await vi.waitFor(() => { + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([english]) + }) + c.ctx.locale.setLocale('zh') + expect(generalLabel(c)).toBe('通用设置') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese]) }) - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General') - b.locale.setLocale('zh') - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置') }) - it('reads availability from the shared mirror and follows its reconnect refresh', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const entry = b.slots.entries('settings.action')[0]! - const { controller } = (entry.inject as unknown as () => SettingsDocumentActionInjected)() - // The mirror read once at its own boot; the action's load adds no read. - await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledOnce() }) + it('reads availability from the shared mirror and follows its reconnect refresh', async ({ mock, start }) => { + const { c } = await client(mock, start, true) + const { controller } = actionInjectedOf(c) + // Boot reads the document twice: the mirror's own `ensure` at apply, then + // the `connection/reset` of the first connection. The action's load adds none. + expect(c.mock.log.calls('settings/describe')).toHaveLength(2) await controller.load() - expect(b.settingsDescribe).toHaveBeenCalledOnce() + expect(c.mock.log.calls('settings/describe')).toHaveLength(2) expect(controller.store.getSnapshot().status).toBe('ready') - b.ctx.emit('connection/reset') - await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) }) + c.connection.reconnect() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { expect(c.mock.log.calls('settings/describe')).toHaveLength(3) }) }) - it('withholds the Host document action off-loopback', async () => { - const b = await bench(false) - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.entries('settings.action')).toEqual([]) - expect(b.settingsDescribe).not.toHaveBeenCalled() - await fiber.dispose() - for (const [name] of SEATS) expect(b.slots.entries(name)).toEqual([]) + it('withholds the Host document action off-loopback', async ({ mock, start }) => { + const loopbackUrl = location.href + setPageUrl('http://198.51.100.7:3000/') + onTestFinished(() => { setPageUrl(loopbackUrl) }) + const { c } = await client(mock, start) + expect(c.connection.isLoopback).toBe(false) + expect(ownEntries(c, 'settings.action')).toEqual([]) + // Off-loopback settings stay process-local: no describe read, so the browser language stands. + expect(c.mock.log.calls('settings/describe')).toEqual([]) + expect(c.ctx.locale.getSnapshot().active).toBe('en') + await c.unload(SELF) + await c.flush() + for (const [name] of SEATS) expect(ownEntries(c, name)).toEqual([]) }) - it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => { - const b = await bench() - const redeclare = declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - // Declarer unload: the cascade removes every seat entry and the item - // declaration while our local disposers go stale. - redeclare() - for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) - expect(b.slots.spec('settings.general.item')).toBeUndefined() - declare(b.slots) - await Promise.resolve() - for (const [name, component] of SEATS) { - expect(b.slots.entries(name)[0]!.component).toBe(component) - } - expect(b.slots.entries('settings.general.item')).toEqual([]) - expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + it('re-registers after a Loader rebuild of the declaring chain (stale disposers must not block)', async ({ mock, start }) => { + const { c, settings } = await client(mock, start) + const before = SEATS.map(([name]) => ownEntries(c, name)[0]) + await c.reload(SIDEBAR) + await c.flush() + expectSeated(c) + SEATS.forEach(([name], index) => { + expect(ownEntries(c, name)[0]).not.toBe(before[index]) + }) + expect(c.ctx.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + expect(c.ctx.slots.entries('settings.general.item').filter(row => row.locale === NS)).toEqual([]) // The recovered registrations still ride the locale path. - b.locale.setLocale('en') - expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('General') - b.locale.setLocale('zh') + const english = localeView('en', 1) + const chinese = localeView('zh', 2) + settings.mutate.mockResolvedValueOnce(ok(english)).mockResolvedValueOnce(ok(chinese)) + c.ctx.locale.setLocale('en') + expect(generalLabel(c)).toBe('General') + c.ctx.locale.setLocale('zh') + expect(generalLabel(c)).toBe('通用设置') + await vi.waitFor(() => { + expect(settings.mutate.mock.calls).toEqual([ + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'en' }], 0], + [LOCALE_SETTINGS_NAMESPACE, [{ op: 'set', path: ['preference'], value: 'zh' }], 1], + ]) + expect(c.ctx.settingsScope.describe().getSnapshot().view?.namespaces).toEqual([chinese]) + }) }) - it('removes every seat and the item declaration on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - expect(b.slots.spec('settings.general.item')).toBeDefined() - await fiber.dispose() - for (const [name] of SEATS) expect(b.slots.entries(name)).toHaveLength(0) - expect(b.slots.spec('settings.general.item')).toBeUndefined() + it('removes every seat and the item declaration when its row unloads', async ({ mock, start }) => { + const { c } = await client(mock, start) + expect(c.ctx.slots.spec('settings.general.item')).toBeDefined() + await c.unload(SELF) + await c.flush() + for (const [name] of SEATS) expect(ownEntries(c, name)).toHaveLength(0) + expect(c.ctx.slots.spec('settings.general.item')).toBeUndefined() }) }) diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index a6a953aa34..01bc43a45b 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -162,14 +162,57 @@ describe('SettingsRoot trigger', () => { expect(mounted.reconnect).toHaveBeenCalledOnce() mounted.setConnectionState('connecting') - expect(screen.getByRole('button', { name: 'Reconnecting automatically, reconnect now' }).textContent) + expect(screen.getByRole('button', { name: 'Reconnecting, reconnect now' }).textContent) .toContain('Reconnecting...') + // An attempt that resolves instantly still shows the connecting pill for + // its 800ms minimum before the confirmation replaces it. mounted.setConnectionState('connected') + expect(screen.queryByRole('status')).toBeNull() + act(() => { vi.advanceTimersByTime(800) }) expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + // The confirmation window is measured from visibility, not the transition. act(() => { vi.advanceTimersByTime(1_999) }) expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + // The confirmation window closes at 2s, then the pill fades for 150ms. act(() => { vi.advanceTimersByTime(1) }) + act(() => { vi.advanceTimersByTime(150) }) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('keeps the attempt label steady through the hold and confirms for the full window', () => { + vi.useFakeTimers() + const mounted = mount({ dictionary: zh }) + mounted.setConnectionState('connecting') + const attempt = screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }) + expect(attempt.textContent).toContain('重新连接中') + fireEvent.click(attempt) + expect(mounted.reconnect).toHaveBeenCalledOnce() + expect(attempt.textContent).toContain('重新连接中') + // An attempt that resolves mid-hold keeps its label until the hold ends. + act(() => { vi.advanceTimersByTime(100) }) + mounted.setConnectionState('connected') + expect(screen.getByRole('button', { name: '连接中断,正在重试,点击立即重连' }).textContent) + .toContain('重新连接中') + act(() => { vi.advanceTimersByTime(700) }) + expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy() + // The full two-second confirmation follows the delayed appearance. + act(() => { vi.advanceTimersByTime(1_999) }) + expect(screen.getByRole('status', { name: '连接成功' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) + act(() => { vi.advanceTimersByTime(150) }) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('skips the hold when the attempt already stayed visible long enough', () => { + vi.useFakeTimers() + const mounted = mount() + mounted.setConnectionState('connecting') + act(() => { vi.advanceTimersByTime(800) }) + mounted.setConnectionState('connected') + expect(screen.getByRole('status', { name: 'Connected' })).toBeTruthy() + act(() => { vi.advanceTimersByTime(2_000) }) + act(() => { vi.advanceTimersByTime(150) }) expect(screen.queryByRole('status')).toBeNull() }) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 8e33a75a10..24ca33b895 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -1,54 +1,25 @@ -/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' -import { apply, inject } from '../src/client/index.ts' +// @vitest-environment jsdom +/** + * Settings shell registration inside the assembled web client: the shell + * occupies the `sidebar.settings` hole ui-sidebar declares, its ledger + * projections read the product's real sections, its connection control is the + * roster's Connection, and it survives a Loader rebuild of the declarer. + */ +import { describe, expect, vi } from 'vitest' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { inject } from '../src/client/index.ts' import type { SettingsRootInjected } from '../src/client/shell-contract.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' -async function bench() { - const ctx = new Context() - await ctx.plugin(SlotRegistry).await() - // Copy machinery the shell only reads a revision from; the real locale - // plugin would drag its own settings-row dependencies into this bench. - ctx.provide('locale', { - register: () => () => {}, - bind: () => (key: string) => key, - getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }), - subscribe: () => () => {}, - } as never) - // The shell mounts ui-settings, which injects `remote.settings`; without the - // namespace provided its fiber parks and no slot is ever declared. - const settings = { - describe: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'no settings', {}) }), - } - const reconnect = vi.fn() - const connectionState = { - getSnapshot: () => 'connected' as const, - subscribe: () => () => {}, - } - ctx.provide('connection', { state: connectionState, reconnect } as never) - ctx.provide('remote', { - $on: () => () => {}, - $host: { home: undefined, isLoopback: false }, - settings, - } as never) - ctx.provide('remote.settings', settings as never) - await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, connectionState, reconnect } -} +const SELF = '@deepseek-ai/dsh-client-ui-settings-general' +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +const it = createClientTest({ roster: webApp }) +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 -function declare(slots: SlotRegistry): () => void { - return slots.register( - { name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} - -function injectedOf(slots: SlotRegistry): SettingsRootInjected { - const entry = slots.entries('sidebar.settings')[0]! +function injectedOf(c: TestClient): SettingsRootInjected { + const entry = c.ctx.slots.entries('sidebar.settings')[0]! return (entry.inject as () => SettingsRootInjected)() } @@ -61,82 +32,74 @@ const CHILD_SPECS = { 'settings.section': { kind: 'list', scope: 'root' }, 'settings.onboarding': { kind: 'list', scope: 'root' }, } as const +const CHILD_NAMES = Object.keys(CHILD_SPECS) as Array -describe('ui-settings apply', () => { - it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', - ]) +/** + * Section ids the web-app roster registers, in nav order: this package, then + * ui-settings-models, ui-settings-plugins, and ui-agent-preset. A plugin adding + * a section changes this list. + */ +const PRODUCT_SECTIONS: readonly string[] = ['general', 'models', 'plugins', 'agent-presets'] +/** Onboarding steps the web-app roster registers, in coordinator order; both come from ui-settings-models. */ +const PRODUCT_ONBOARDING: readonly { id: string; order: number }[] = [ + { id: 'welcome-notice', order: -100 }, + { id: 'deepseek-official', order: 0 }, +] + +describe('ui-settings-general shell', () => { + it('declares its services', () => { + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) - it('registers the shell and declares every child slot, before or after the declaration', async () => { - const before = await bench() - declare(before.slots) - await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name]) - } + it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => { + const c = await start() + expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot]) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) + }, COLD_BOOT_TIMEOUT_MS) - const after = await bench() - await after.ctx.plugin({ inject: [...inject], apply }).await() - expect(after.slots.entries('sidebar.settings')).toHaveLength(0) - declare(after.slots) - await Promise.resolve() - expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - // The self-inflicted ledger notifications hit the duplicate guard. - expect(after.slots.entries('sidebar.settings')).toHaveLength(1) - }) - - it('projects the section ledger into ordered nav rows with option defaults', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const { sections } = injectedOf(b.slots).hooks - // This package registers the General section itself; every other section - // arrives from a feature registrant. - const GENERAL = { id: 'general', order: 0, label: 'general.nav' } - expect(sections.getSnapshot()).toEqual([GENERAL]) - b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) - // No order and no label: both projection defaults apply. - b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) + it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => { + const c = await start() + const { sections } = injectedOf(c).hooks + const product = sections.getSnapshot() + expect(product.map(row => row.id)).toEqual(PRODUCT_SECTIONS) + expect(product[0]).toEqual({ id: 'general', order: 0, label: expect.any(String) as string }) + c.ctx.slots.register({ name: 'settings.section', id: 'z', order: 1_000, label: 'Z' } as never, () => null) + // No order and no label: both projection defaults apply, and order 0 sorts among the product rows. + c.ctx.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) const rows = sections.getSnapshot() - expect(rows).toEqual([ - GENERAL, - { id: 'a', order: 0, label: '' }, - { id: 'z', order: 20, label: 'Z' }, - ]) + expect(rows.at(-1)).toEqual({ id: 'z', order: 1_000, label: 'Z' }) + expect(rows.find(row => row.id === 'a')).toEqual({ id: 'a', order: 0, label: '' }) + expect(rows.map(row => row.order)).toEqual([...rows.map(row => row.order)].sort((x, y) => x - y)) // Snapshot identity is stable until the ledger moves (uSES contract). expect(sections.getSnapshot()).toBe(rows) const listener = vi.fn() const off = sections.subscribe(listener) - b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) + c.ctx.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalled() expect(sections.getSnapshot()).not.toBe(rows) off() }) - it('projects the Gateway connection control without copying its state', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots) - expect(injected.hooks.connectionState).toBe(b.connectionState) + it('projects the roster Connection control without copying its state; reconnect opens a new $events generation', async ({ start }) => { + const c = await start() + const injected = injectedOf(c) + expect(injected.hooks.connectionState).toBe(c.connection.state) + expect(injected.hooks.connectionState.getSnapshot()).toBe('connected') injected.reconnect() - expect(b.reconnect).toHaveBeenCalledOnce() + await c.mock.streams.opened('$events', 2) + await vi.waitFor(() => { expect(c.connection.state.getSnapshot()).toBe('connected') }) }) - it('projects onboarding entries into stable coordinator order', async () => { - const b = await bench() - declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - const { onboardingSteps } = injectedOf(b.slots).hooks - b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) - b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) - b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) + it('projects onboarding entries into stable coordinator order', async ({ start }) => { + const c = await start() + const { onboardingSteps } = injectedOf(c).hooks + expect(onboardingSteps.getSnapshot()).toEqual(PRODUCT_ONBOARDING) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) const steps = onboardingSteps.getSnapshot() - expect(steps).toEqual([ + expect(steps.filter(step => !PRODUCT_ONBOARDING.some(known => known.id === step.id))).toEqual([ { id: 'welcome', order: -100 }, { id: 'credential', order: 0 }, { id: 'default-order', order: 0 }, @@ -144,39 +107,28 @@ describe('ui-settings apply', () => { expect(onboardingSteps.getSnapshot()).toBe(steps) const listener = vi.fn() const off = onboardingSteps.subscribe(listener) - b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) + c.ctx.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalledOnce() off() }) - it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { - const b = await bench() - const redeclare = declare(b.slots) - await b.ctx.plugin({ inject: [...inject], apply }).await() - expect(b.slots.entries('sidebar.settings')).toHaveLength(1) - // Declarer unload: the cascade removes our entry and every child - // declaration while our local disposer variable goes stale. - redeclare() - expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - expect(b.slots.spec('settings.trigger')).toBeUndefined() - declare(b.slots) - await Promise.resolve() - expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name]) - } + it('re-registers after the declarer reloads: the cascade removes the shell, the rebuilt declaration takes it back', async ({ start }) => { + const c = await start() + const before = c.ctx.slots.entries('sidebar.settings')[0] + expect(before).toBeDefined() + await c.reload(SIDEBAR) + await c.flush() + expect(c.ctx.slots.entries('sidebar.settings').map(entry => entry.component)).toEqual([SettingsRoot]) + expect(c.ctx.slots.entries('sidebar.settings')[0]).not.toBe(before) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) }) - it('unregisters the shell and collapses every child slot on teardown', async () => { - const b = await bench() - declare(b.slots) - const fiber = b.ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - await fiber.dispose() - expect(b.slots.entries('sidebar.settings')).toHaveLength(0) - for (const name of Object.keys(CHILD_SPECS) as Array) { - expect(b.slots.spec(name)).toBeUndefined() - } + it('unregisters the shell and collapses every child slot when its row unloads', async ({ start }) => { + const c = await start() + await c.unload(SELF) + await c.flush() + expect(c.ctx.slots.entries('sidebar.settings')).toHaveLength(0) + for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toBeUndefined() }) }) diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index b7272db74f..6702d7d942 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 84a760e1f3..da1dd00f50 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -1,10 +1,12 @@ /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, scriptedSettingsRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { remoteDefaultResponses } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses.ts' +import { ok, RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { @@ -19,7 +21,8 @@ import { apply as hostApply } from '../src/index.ts' // so browser-language detection never runs and a fresh LocaleRuntime opens on // FALLBACK_LOCALE (en); bench stages zh explicitly on the locale instead. -async function bench(isLoopback = true, settings?: object, services: object = {}) { +async function bench(isLoopback = true, mock = RemoteMock.create().load(remoteDefaultResponses), services: object = {}) { + onTestFinished(() => { mock.assertNoUnmatched() }) const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) @@ -37,10 +40,7 @@ async function bench(isLoopback = true, settings?: object, services: object = {} discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })), ...services, }, - // Without a settings face the mirror's reads fail and stay contained; the - // Models join itself never fetches until a section actually loads. The real - // ui-settings apply also provides the settingsSchema service. - settings: settings ?? scriptedSettingsRemote().settings, + settings: mock.remote.settings, }) // The fixed Host facts the settings provider reads its persistence from. remote.$host = { home: undefined, isLoopback } @@ -259,25 +259,18 @@ describe('pushed invalidations', () => { it('welcome state follows the shared mirror across document commits', async () => { // The welcome notice derives from its settings scope: a document commit // reaches it through the mirror's one refresh, with no routing here. - const acknowledgement = { current: undefined as string | undefined } - const settings = { - describe: vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, - schema: {}, - value: acknowledgement.current === undefined ? {} : { [WELCOME_NOTICE_ACK_FIELD]: acknowledgement.current }, - applies: 'live' as const, - secrets: [], - revision: 0, - }], - }, - })), + const mock = RemoteMock.create().load(remoteDefaultResponses) + const namespace = { + ns: WELCOME_NOTICE_SETTINGS_NAMESPACE, + schema: {}, + value: {}, + applies: 'live' as const, + secrets: [], + revision: 0, } - const b = await bench(true, settings) + const document = { writable: true, hasDocument: false, namespaces: [namespace] } + mock.remote.settings.describe.mockResolvedValue(ok(document)) + const b = await bench(true, mock) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.onboarding') @@ -290,7 +283,10 @@ describe('pushed invalidations', () => { await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: false }) }) - acknowledgement.current = WELCOME_NOTICE_VERSION + mock.remote.settings.describe.mockResolvedValue(ok({ + ...document, + namespaces: [{ ...namespace, value: { [WELCOME_NOTICE_ACK_FIELD]: WELCOME_NOTICE_VERSION }, revision: 1 }], + })) b.remote.emit('settings/document-updated', ['ui-onboarding', 1]) await vi.waitFor(() => { expect(injected.hooks.welcome.getSnapshot()).toMatchObject({ status: 'ready', acknowledged: true }) @@ -298,24 +294,13 @@ describe('pushed invalidations', () => { }) it('joins the refreshed mirror view on a settings invalidation', async () => { - let revision = 1 - const describe = vi.fn(() => Promise.resolve({ - ok: true as const, - value: { - writable: true, - hasDocument: false, - namespaces: [{ - ns: 'llm-test', - schema: {}, - value: {}, - applies: 'live' as const, - secrets: [], - revision, - }], - }, - })) + const mock = RemoteMock.create().load(remoteDefaultResponses) + const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 } + const document = { writable: true, hasDocument: false, namespaces: [namespace] } + const describe = mock.remote.settings.describe + describe.mockResolvedValue(ok(document)) const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) - const b = await bench(true, { describe }, { listProviders }) + const b = await bench(true, mock, { listProviders }) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.section') @@ -327,8 +312,8 @@ describe('pushed invalidations', () => { await injected.controller.load() expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(1) - revision = 2 - b.remote.emit('settings/document-updated', ['llm-test', revision]) + describe.mockResolvedValue(ok({ ...document, namespaces: [{ ...namespace, revision: 2 }] })) + b.remote.emit('settings/document-updated', ['llm-test', 2]) await vi.waitFor(() => { expect(injected.hooks.snapshot.getSnapshot().namespaces.get('llm-test')?.revision).toBe(2) diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 4bf7c82dbd..a0e43dbaa4 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 509ab80afc..a9ee3769c3 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 909e4545db..d8c8fd594a 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index b8f78014ce..c84f696313 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview", "description": "Extensible document previews for Sidebar files: Markdown, highlighted code, images, PDF, HTML, and plain text", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index cfa515b3dd..c9b0f30663 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-files", "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 4c6786e22d..05c813fcc2 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-right", "description": "Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 1529d8a370..e3329d2ad5 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 563296bded..9f1df843ed 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 3da3989d32..aa3ea2b5db 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 75a68280a9..013105cce6 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 0e687ca13c..36b96066e3 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: 7c5ba6541601bbe506a0707e3ebfd75a1f30ade9 -README.zh.md: f792f5c73aa295e06241899d0eb1a8812cad6b13 +README.md: 291492b9b41df6570722fb2dbd14565c660ec350 +README.zh.md: 08cef7aca2f6df648fe75b3d27aed93c44a2d30c diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 7c5ba65416..291492b9b4 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -59,7 +59,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Scrollbar rebinding -`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align beside a space-consuming bar. The two rendering paths are mutually exclusive by construction: Firefox takes the standard properties inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. +`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). WebKit-based browsers also read `--dsh-scrollbar-width`, `--dsh-scrollbar-thumb-border`, and `--dsh-scrollbar-track-margin`; a scroll surface may rebind them to keep a wide draggable rail around a narrower visible thumb or to inset the track from rounded ends. The two rendering paths are mutually exclusive by construction: Firefox takes the standard thin scrollbar inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so geometry and hover customization apply only through the pseudo-element path. ### Preference persistence diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index f792f5c73a..08cef7aca2 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -59,7 +59,7 @@ kind: "package-reference" ### 滚动条重新绑定 -`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准属性,WebKit 系引擎走伪元素,因此 hover token 只经由伪元素这条路径渲染。 +`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。WebKit 系浏览器还会读取 `--dsh-scrollbar-width`、`--dsh-scrollbar-thumb-border` 与 `--dsh-scrollbar-track-margin`;滚动表面可重新绑定它们,在较窄的可见滑块外保留较宽的拖动区域,或让轨道避开圆角两端。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准细滚动条,WebKit 系引擎走伪元素,因此几何与 hover 定制只经由伪元素路径生效。 ### 偏好持久化 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 33f5cb95a7..5a7a7fb249 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/src/styles/scrollbar.css b/packages/client/ui-theme/src/styles/scrollbar.css index 71f67d4f57..d61bcbcedb 100644 --- a/packages/client/ui-theme/src/styles/scrollbar.css +++ b/packages/client/ui-theme/src/styles/scrollbar.css @@ -10,13 +10,14 @@ * * Surfaces pick their elevation by rebinding --dsh-scrollbar-thumb{,-hover}: * the l1 pair here is the base-surface default, and an elevated surface - * (menu, popover, dialog) rebinds to the l2 pair on its own container. Both - * rendering paths below read the indirection, so one rebind reaches whichever - * path the engine took. */ + * (menu, popover, dialog) rebinds to the l2 pair on its own container. A + * scroll container may also rebind the three geometry variables below. */ body { --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1); + --dsh-scrollbar-thumb-border: 0px; + --dsh-scrollbar-track-margin: 0px; /* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule below. A surface that must align itself beside a space-consuming bar (ConversationRoot's overlay composer seat) reads this instead of @@ -64,23 +65,27 @@ body { selector matching already does. Not inherited either, hence the unscoped selectors. */ ::-webkit-scrollbar { - width: 8px; - height: 8px; + width: var(--dsh-scrollbar-width); + height: var(--dsh-scrollbar-width); } /* Track stays transparent so the thumb reads against whatever surface scrolls under it; only the thumb carries a token colour. */ ::-webkit-scrollbar-track { + margin-block: var(--dsh-scrollbar-track-margin); background: transparent; } ::-webkit-scrollbar-thumb { - border-radius: 4px; + border: var(--dsh-scrollbar-thumb-border) solid transparent; + border-radius: 999px; + corner-shape: round; background: var(--dsh-scrollbar-thumb); + background-clip: content-box; } ::-webkit-scrollbar-thumb:hover { - background: var(--dsh-scrollbar-thumb-hover); + background-color: var(--dsh-scrollbar-thumb-hover); } /* Both scrollbars meeting in a corner: no separate token, so the corner diff --git a/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts index c772bc92d5..9682bdeb3c 100644 --- a/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts @@ -2,9 +2,9 @@ * Scrollbar stylesheet contract, asserted against the CSS text on disk: every * --dsw-alias-scrollbar-* token design-platform.css defines has a consumer, * scrollbar.css binds the base-surface pair through the rebindable - * indirection, the width variable mirrors the ::-webkit-scrollbar rule for - * consumers that align beside the bar, and elevated surfaces rebind that - * indirection in complete pairs. The expected token set is scanned out of + * indirection, the WebKit geometry reads the shared width, thumb-border, and + * track-margin variables, and elevated surfaces rebind the colour indirection + * in complete pairs. The expected token set is scanned out of * design-platform.css, so adding, renaming, or dropping a scrollbar token * moves these assertions with it. */ @@ -25,6 +25,11 @@ const DARK_ATTRIBUTE = '[data-ds-dark-theme]' const TOKEN_PREFIX = '--dsw-alias-scrollbar-' /** Prefix of the rebindable indirection scrollbar.css owns. */ const INDIRECTION_PREFIX = '--dsh-scrollbar-' +/** The elevation-aware colour variables surfaces rebind as one pair. */ +const COLOUR_INDIRECTIONS = new Set([ + `${INDIRECTION_PREFIX}thumb`, + `${INDIRECTION_PREFIX}thumb-hover`, +]) /** The one non-token rebind value: a surface that draws no thumb at all. */ const HIDDEN_THUMB = 'transparent' /** The elevation rebind, spelled per property: value-wholeness, not token shape. */ @@ -68,6 +73,14 @@ const platformRules = parseRules(platformCss) const scrollbarRules = parseRules(scrollbarCss) const sorted = (names: Iterable): string[] => [...names].sort() +/** Last value one selector declares for a property. */ +function declaration(rules: CssRule[], property: string, selectorPart: string): string | undefined { + return rules + .filter(rule => rule.selectors.includes(selectorPart)) + .flatMap(rule => rule.declarations) + .findLast(([name]) => name === property)?.[1] +} + /** * Scrollbar tokens defined by the rules whose selectors carry (or do not * carry) the dark palette attribute. @@ -175,7 +188,7 @@ for (const file of packageStylesheets()) { let rebindsElevation = false const ruleSurfaces: string[] = [] for (const [property, value] of rule.declarations) { - if (property.startsWith(INDIRECTION_PREFIX) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) { + if (COLOUR_INDIRECTIONS.has(property) && file !== fileURLToPath(new URL('scrollbar.css', STYLES))) { rebinds = true if (value !== HIDDEN_THUMB) rebindsElevation = true } @@ -248,48 +261,39 @@ describe('scrollbar.css base-surface binding', () => { it('routes the standard property and the WebKit thumb through the same indirection', () => { // A rebind on an elevated container has to move the Firefox and the WebKit // rendering together, which only holds while both read the same variable. - const declaration = (property: string, selectorPart: string): string | undefined => scrollbarRules - .filter(rule => rule.selectors.includes(selectorPart)) - .flatMap(rule => rule.declarations) - .findLast(([name]) => name === property)?.[1] - const thumbColor = declaration('scrollbar-color', 'body') + const thumbColor = declaration(scrollbarRules, 'scrollbar-color', 'body') expect(thumbColor).toBeDefined() const indirection = varReferences(thumbColor!)[0] expect(indirection).toBe(`${INDIRECTION_PREFIX}thumb`) - expect(varReferences(declaration('background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection]) + expect(varReferences(declaration(scrollbarRules, 'background', '::-webkit-scrollbar-thumb')!)).toEqual([indirection]) }) }) -describe('scrollbar.css width variable', () => { +describe('scrollbar.css geometry variables', () => { const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width` + const THUMB_BORDER_VARIABLE = `${INDIRECTION_PREFIX}thumb-border` + const TRACK_MARGIN_VARIABLE = `${INDIRECTION_PREFIX}track-margin` + const GEOMETRY_VARIABLES = [WIDTH_VARIABLE, THUMB_BORDER_VARIABLE, TRACK_MARGIN_VARIABLE] - it('defines the width variable on body as a static length', () => { - // The overlay seat compensation reads a fixed number, not a second - // indirection: the mirror check below compares the WebKit rule against - // this value, so a var()-to-var() chain would compare one indirection to - // another instead of pinning the number. - const value = scrollbarRules + it('defines each geometry variable on body as a static length', () => { + const definitions = new Map(scrollbarRules .filter(rule => rule.selectors.includes('body')) .flatMap(rule => rule.declarations) - .findLast(([property]) => property === WIDTH_VARIABLE)?.[1] - expect(value, WIDTH_VARIABLE).toBeDefined() - expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/) + .filter(([property]) => GEOMETRY_VARIABLES.includes(property))) + for (const property of GEOMETRY_VARIABLES) { + expect(definitions.get(property), property).toMatch(/^\d+(?:\.\d+)?px$/) + } }) - it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => { - // The compensation stays aligned with the WebKit bar only while both read - // the same number. A change to one side without the other puts the overlay - // seat a band off from Chat on WebKit engines. - const variableValue = scrollbarRules - .filter(rule => rule.selectors.includes('body')) - .flatMap(rule => rule.declarations) - .findLast(([property]) => property === WIDTH_VARIABLE)?.[1] + it('routes WebKit scrollbar geometry through those variables', () => { const webkitWidth = scrollbarRules .filter(rule => rule.selectors.includes('::-webkit-scrollbar')) .flatMap(rule => rule.declarations) .findLast(([property]) => property === 'width')?.[1] expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined() - expect(webkitWidth).toBe(variableValue) + expect(varReferences(webkitWidth!)).toEqual([WIDTH_VARIABLE]) + expect(varReferences(declaration(scrollbarRules, 'border', '::-webkit-scrollbar-thumb')!)).toEqual([THUMB_BORDER_VARIABLE]) + expect(varReferences(declaration(scrollbarRules, 'margin-block', '::-webkit-scrollbar-track')!)).toEqual([TRACK_MARGIN_VARIABLE]) }) it('every reader of the width variable outside ui-theme references a defined variable', () => { @@ -422,10 +426,8 @@ describe('elevated surface rebinds', () => { // A surface rebinding only the resting colour keeps the l1 hover colour, // so the elevation is wrong only while the pointer is over the thumb. for (const { file, rule } of rebindRules) { - const properties = rule.declarations.map(([property]) => property).filter(property => property.startsWith(INDIRECTION_PREFIX)) - expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual([ - `${INDIRECTION_PREFIX}thumb-hover`, `${INDIRECTION_PREFIX}thumb`, - ].sort()) + const properties = rule.declarations.map(([property]) => property).filter(property => COLOUR_INDIRECTIONS.has(property)) + expect(sorted(properties), `${file} ${rule.selectors.join(', ')}`).toEqual(sorted(COLOUR_INDIRECTIONS)) } }) @@ -459,7 +461,7 @@ describe('elevated surface rebinds', () => { // white)` and a crossed pair (the hover token bound to the resting // property); neither is what the contract says. for (const { file, rule } of rebindRules) { - const rebinds = rule.declarations.filter(([property]) => property.startsWith(INDIRECTION_PREFIX)) + const rebinds = rule.declarations.filter(([property]) => COLOUR_INDIRECTIONS.has(property)) const where = `${file} ${rule.selectors.join(', ')}` if (rebinds.every(([, value]) => value === HIDDEN_THUMB)) continue expect(rebinds.some(([, value]) => value === HIDDEN_THUMB), `${where}: mixes ${HIDDEN_THUMB} with an elevation`).toBe(false) diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 0a4b9fc85c..8e2770e323 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index fdd4ee0700..6eb6fc741f 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 63bf522d57..753e95f5e6 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md -README.md: 9e5997eb2ebe69b4b2d49b57a61b0bd36ec41053 -README.zh.md: 44a73607f95e7859b344034b5abaa2c386619083 +README.md: 5a31c688d7f7ea9a9ad10877628a333d797ac06d +README.zh.md: 4db6cfa9fdead65c8e1c567981d0c16d92e9cb1e diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9e5997eb2e..5a31c688d7 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the composer becomes the question surface: answe ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip this question" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item, while close rejects the whole wait as `ASK_CANCELLED`. ### The plan-review card diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 44a73607f9..4db6cfa9fd 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 ### plan-review 卡片 diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index afaca0b4c7..43a56f951a 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index b3c4232c25..e54243883b 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -11,7 +11,7 @@ export const zh = { 'nav.cancel': '放弃整组问题', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', - 'action.skip': '跳过本题', + 'action.skip': '跳过', 'action.next': '下一题', 'plan.header': '计划待审', 'plan.approve': '确认执行', @@ -33,7 +33,7 @@ export const en = { 'nav.cancel': 'Dismiss all questions', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', - 'action.skip': 'Skip this question', + 'action.skip': 'Skip', 'action.next': 'Next', 'plan.header': 'Plan review', 'plan.approve': 'Approve', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 90848b3e9e..41682b0c6c 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -247,9 +247,9 @@ describe('QuestionComposer', () => { expect((screen.getByText('下一题').closest('button') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(screen.getByRole('radio', { name: '研究潜力型' })) expect(screen.getByText('2 / 3')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) + fireEvent.click(screen.getByRole('button', { name: '跳过' })) expect(screen.getByText('3 / 3')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '跳过本题' })) + fireEvent.click(screen.getByRole('button', { name: '跳过' })) expect(answer).toHaveBeenCalledWith(answerBatch([ { id: 'profile', selected: ['研究潜力型'] }, @@ -346,7 +346,7 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第一次取消失败')).toBeTruthy() - expect(screen.getByRole('button', { name: '跳过本题' }).disabled).toBe(false) + expect(screen.getByRole('button', { name: '跳过' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第二次取消失败')).toBeTruthy() @@ -387,7 +387,7 @@ describe('QuestionComposer', () => { const { carrier } = wait([{ id: 'detail', question: '补充你的要求' }]) render() expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Skip' })).toBeTruthy() expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy() }) diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index b0d6fb3d7a..ac5afa61bf 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 432bfa873d..086992fd19 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 394c689596..4ff4a942cd 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/web/README.md -README.md: 723a68ed401558021ca404b4a81e04c0d18d71be -README.zh.md: 4f6f8eb746ba8ad8715ba4bccf4c3573aa4be24b +README.md: 7bdcec9e2a964b746cbd808497f9222e2b4150fa +README.zh.md: a7243e52a7bababebb75a0ee9f4e22a99fa83337 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index 723a68ed40..7bdcec9e2a 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -35,7 +35,7 @@ Boot runs in two stages: the module stage adopts the parser-loaded bootstrap bat ### The boot page -The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import error, or state) instead of a blank page. +The boot page uses plain DOM and local CSS, so bundle and plugin-activation failures remain visible: it shows one spinner node whose CSS arc grows as entries activate, and reports per-entry status. The spinner and its animation phase persist until the full UI replaces the boot page. A plugin that fails import or activation is reported by name with the reason (missing service, import failure, or state) instead of a blank page. The console contains the original import error. ### The shared module table @@ -72,7 +72,9 @@ The boot page is plain DOM with local CSS whose fallback fonts and colors match | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Library entry: `AppWebEntry`, `getStaticModules`, platform tables | -| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: two-stage boot, activation audit, renderer handoff | +| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: module stage, boot page, immediate-tier prefetch, then `bootClient` + `mountClient` | +| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`: Loader mount, one entry per manifest row, activation audit | +| [`src/mount.ts`](src/mount.ts) | `mountClient`: renderer handoff through a `uiRenderer` dependency fiber | | [`src/boot-page.ts`](src/boot-page.ts) | Framework-free boot page: spinner, per-entry status, failure rendering | | [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`: the implicit external baseline | | [`src/seed.ts`](src/seed.ts) | Static module table handed to the loader at boot | diff --git a/packages/client/web/README.zh.md b/packages/client/web/README.zh.md index 4f6f8eb746..a7243e52a7 100644 --- a/packages/client/web/README.zh.md +++ b/packages/client/web/README.zh.md @@ -35,7 +35,7 @@ kind: "package-library" ### 启动页 -启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入错误或状态),而不是白屏。 +启动页只使用原生 DOM 与本地 CSS,因此 bundle 与插件激活失败保持可见:它显示一个 spinner 节点,其 CSS 圆弧随 entry 激活而增长,并逐 entry 报告状态。spinner 及其动画相位会一直保留,直到完整 UI 替换启动页。导入或激活失败的插件会按名称报告并给出原因(缺失服务、导入失败或状态),而不是白屏。控制台包含原始导入错误。 ### 共享模块表 @@ -72,7 +72,9 @@ kind: "package-library" | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 库入口:`AppWebEntry`、`getStaticModules`、平台表 | -| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:两阶段启动、激活审计、渲染器交接 | +| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:模块阶段、启动页、immediately 层级预取,随后调用 `bootClient` + `mountClient` | +| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`:挂载 Loader、每个 manifest 行一个 entry、激活审计 | +| [`src/mount.ts`](src/mount.ts) | `mountClient`:经 `uiRenderer` 依赖 fiber 完成渲染器交接 | | [`src/boot-page.ts`](src/boot-page.ts) | 无框架启动页:spinner、逐 entry 状态、失败渲染 | | [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`:隐式 external 基座 | | [`src/seed.ts`](src/seed.ts) | 启动时交给 loader 的静态模块表 | diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 36b3bf6251..4b84e01cf3 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/src/boot-client.ts b/packages/client/web/src/boot-client.ts new file mode 100644 index 0000000000..110d8530cb --- /dev/null +++ b/packages/client/web/src/boot-client.ts @@ -0,0 +1,83 @@ +/** + * Production client composition without the page: mount the Loader over a + * module system, create every manifest row, wait for quiescence, and audit + * activation. `AppWebEntry` and the whole-client test carrier both call it. + * @module @deepseek-ai/dsh-client-web/src/boot-client + */ +import type { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import type { BootManifest, ClientModuleLoader } from '@deepseek-ai/dsh-client-modules/client' +import { STATE_LABELS } from './loader-status.ts' + +/** Entry state label as the boot page renders it. */ +export type EntryStateLabel = (typeof STATE_LABELS)[keyof typeof STATE_LABELS] | 'loading' | 'failed' + +/** Inputs of {@link bootClient}. */ +export interface ClientBootOptions { + /** Fresh root Context that will own the plugin tree. */ + readonly ctx: Context + /** Module system installed as `loader.internal`. */ + readonly modules: ClientModuleLoader + /** Parsed manifest whose `plugins` rows become Loader entries (entry name = row id). */ + readonly manifest: BootManifest + /** Per-entry state reporting (the boot page); omitted when no one renders progress. */ + readonly onEntryState?: (name: string, state: EntryStateLabel) => void +} + +/** + * Compose the client: `ctx.plugin(Loader)`, `loader.internal = modules`, one + * `loader.create({ name })` per manifest row, `loader.await()`, then + * {@link assertEntriesActive}. A row whose module cannot be imported is marked + * failed; the Loader logs its import error and the audit rejects startup. + * @param options - context, module system, manifest, optional progress sink. + * @returns resolves after every entry is active; rejects with the audit report otherwise. + */ +export async function bootClient(options: ClientBootOptions): Promise { + const { ctx, manifest, onEntryState } = options + await ctx.plugin(Loader) + const loader = ctx.loader + loader.internal = options.modules as never + + ctx.on('internal/status', (fiber) => { + const entry = fiber.entry + if (entry === undefined || entry.fiber === undefined) return + onEntryState?.(entry.options.name, STATE_LABELS[entry.fiber.state]) + }) + + const rows = manifest.plugins.map(row => row.id) + await Promise.all(rows.map(async (name) => { + onEntryState?.(name, 'loading') + const id = await loader.create({ name }) + if (loader.resolve(id).fiber === undefined) onEntryState?.(name, 'failed') + })) + + await loader.await() + assertEntriesActive(ctx) +} + +/** + * Reject entries that failed import/apply or still wait on missing services. + * @param ctx - root Context carrying the Loader. + * @throws {Error} listing every non-active entry with its reason. + */ +export function assertEntriesActive(ctx: Context): void { + const failures: string[] = [] + for (const entry of ctx.loader.entries()) { + const name = entry.options.name + if (entry.fiber === undefined) { + failures.push(`${name}: import failed (see console for the import error)`) + continue + } + const state = STATE_LABELS[entry.fiber.state] + if (state === 'active') continue + if (state === 'pending') { + const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) + failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) + } else { + failures.push(`${name}: ${state}`) + } + } + if (failures.length > 0) { + throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) + } +} diff --git a/packages/client/web/src/boot.ts b/packages/client/web/src/boot.ts index 71ab65c140..84b2d5bdac 100644 --- a/packages/client/web/src/boot.ts +++ b/packages/client/web/src/boot.ts @@ -1,18 +1,18 @@ /** * Web boot kernel. It owns only the module system, Cordis loader, and a - * framework-free boot page. The dynamic UI renderer receives the mount + * framework-free boot page; plugin composition and the renderer handoff are + * `bootClient` and `mountClient`. The dynamic UI renderer receives the mount * point after every client entry activates. * @module @deepseek-ai/dsh-client-web/src/boot */ import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' import type { BootManifest, ClientModuleCreateOptions, ClientModuleSystem, DshWindow, } from '@deepseek-ai/dsh-client-modules/client' -import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { bootClient } from './boot-client.ts' import { BootPage } from './boot-page.ts' +import { mountClient } from './mount.ts' import { getStaticModules } from './seed.ts' -import { STATE_LABELS } from './loader-status.ts' import './base.css' /** Module transport hook replaced by jsdom tests. */ @@ -76,8 +76,15 @@ export class AppWebEntry { const prefetching = this.prefetchImmediateTier() const ctx = new Context() this.ctx = ctx - await this.runPluginBoot(ctx, prefetching) - await this.mountApp(ctx) + this.page.setTotal(this.manifest.plugins.length) + await prefetching + await bootClient({ + ctx, + modules: this.modules, + manifest: this.manifest, + onEntryState: (name, state) => { this.page.setState(name, state) }, + }) + await mountClient(ctx, this.container) } catch (reason) { console.error(reason) this.page.fail(reason instanceof Error ? reason.message : String(reason)) @@ -92,14 +99,6 @@ export class AppWebEntry { this.page.dispose() } - /** Mount through a dependency fiber so replacing uiRenderer remounts the application. */ - private async mountApp(ctx: Context): Promise { - const mounted = ctx.inject(['uiRenderer'], (scope) => { - scope.effect(() => scope.uiRenderer.mount(this.container), 'web boot: application mount') - }) - await mounted - } - /** Prefetch stage-one bundles and their dynamic requests before concurrent plugin imports. */ private async prefetchImmediateTier(): Promise { await Promise.all(this.manifest.plugins @@ -108,52 +107,4 @@ export class AppWebEntry { // Prefetch only starts transport early; the Loader import retries and reports this bundle failure. }))) } - - /** Mount the Loader, create all graph entries, await quiescence, and audit activation. */ - private async runPluginBoot(ctx: Context, prefetching: Promise): Promise { - await ctx.plugin(Loader) - const loader = ctx.loader - loader.internal = this.modules as never - - ctx.on('internal/status', (fiber) => { - const entry = fiber.entry - if (entry === undefined || entry.fiber === undefined) return - this.page.setState(entry.options.name, STATE_LABELS[entry.fiber.state]) - }) - - const rows = this.manifest.plugins.map(row => row.id) - this.page.setTotal(rows.length) - await prefetching - await Promise.all(rows.map(async (name) => { - this.page.setState(name, 'loading') - const id = await loader.create({ name }) - if (loader.resolve(id).fiber === undefined) this.page.setState(name, 'failed') - })) - - await loader.await() - this.assertEntriesActive(ctx) - } - - /** Reject entries that failed import/apply or still wait on missing services. */ - private assertEntriesActive(ctx: Context): void { - const failures: string[] = [] - for (const entry of ctx.loader.entries()) { - const name = entry.options.name - if (entry.fiber === undefined) { - failures.push(`${name}: import failed (see console for the import error)`) - continue - } - const state = STATE_LABELS[entry.fiber.state] - if (state === 'active') continue - if (state === 'pending') { - const missing = Object.keys(entry.fiber.inject).filter(service => ctx.get(service) === undefined) - failures.push(`${name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`) - } else { - failures.push(`${name}: ${state}`) - } - } - if (failures.length > 0) { - throw new Error(`web boot: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`) - } - } } diff --git a/packages/client/web/src/mount.ts b/packages/client/web/src/mount.ts new file mode 100644 index 0000000000..4836a92a6d --- /dev/null +++ b/packages/client/web/src/mount.ts @@ -0,0 +1,24 @@ +/** + * Application mount through a dependency fiber, so replacing `uiRenderer` + * remounts the application. Shared by `AppWebEntry` and the test carrier. + * @module @deepseek-ai/dsh-client-web/src/mount + */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' + +/** + * Mount the UI renderer into `container` through a dependency fiber on + * `uiRenderer`: the mount effect installs when the service is provided and + * reinstalls when it is replaced. + * @param ctx - booted root Context. + * @param container - application mount point. + * @returns resolves once the dependency fiber exists; with `uiRenderer` + * already provided (as after `bootClient`) the mount effect is installed by + * then, otherwise it installs when the service arrives. + */ +export async function mountClient(ctx: Context, container: HTMLElement): Promise { + const mounted = ctx.inject(['uiRenderer'], (scope) => { + scope.effect(() => scope.uiRenderer.mount(container), 'web boot: application mount') + }) + await mounted +} diff --git a/packages/client/web/tests/boot-client.client.spec.ts b/packages/client/web/tests/boot-client.client.spec.ts new file mode 100644 index 0000000000..43fe974d80 --- /dev/null +++ b/packages/client/web/tests/boot-client.client.spec.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { + createClientModuleSystem, parseBootManifest, + type ClientBundleRegistration, type ClientModuleLoader, type ClientModuleLoaderTarget, type WebBootEntry, type WebBootGraph, +} from '@deepseek-ai/dsh-client-modules/client' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { assertEntriesActive, bootClient, type EntryStateLabel } from '../src/boot-client.ts' +import { FIBER_STATE } from '../src/loader-status.ts' + +const BOOTSTRAP_ID = '@deepseek-ai/dsh-client-modules' + +function graphOf(ids: readonly string[]): WebBootGraph { + const entries: WebBootEntry[] = ids.map(id => ({ id, url: `/${id}.js`, rev: '1' })) + return { + rev: 'graph', + entries, + batches: [{ phase: 'application', url: '/application.js', rev: 'batch', entries: [...ids] }], + } +} + +/** Module system seeded with inline plugin modules; `loaded` records every transport call. */ +function modulesOf(graph: WebBootGraph, staticModules: Record): { modules: ClientModuleLoader; loaded: string[] } { + const loaded: string[] = [] + const pendingQueue: ClientBundleRegistration[] = [] + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue, + load: (registration) => { pendingQueue.push(registration) }, + create: options => createClientModuleSystem(target, { id: BOOTSTRAP_ID, exports: {} }, options), + } + const modules = target.create({ + boot: graph, + staticModules, + loadBundle: async (url) => { loaded.push(url) }, + }) + return { modules, loaded } +} + +/** Recording progress sink. */ +function stateSink(): { states: Map; onEntryState: (name: string, state: EntryStateLabel) => void } { + const states = new Map() + return { + states, + onEntryState: (name, state) => { states.set(name, [...(states.get(name) ?? []), state]) }, + } +} + +describe('bootClient', () => { + it('activates every seeded row without touching the bundle transport', async () => { + const graph = graphOf(['provider', 'consumer']) + const { modules, loaded } = modulesOf(graph, { + provider: { apply: (ctx: Context) => { ctx.reflect.provide('x', { marker: 'x' }) } }, + consumer: { inject: ['x'], apply: () => {} }, + }) + const ctx = new Context() + const sink = stateSink() + + await bootClient({ ctx, modules, manifest: modules.manifest, onEntryState: sink.onEntryState }) + + expect(loaded).toEqual([]) + const consumer = sink.states.get('consumer') ?? [] + expect(consumer[0]).toBe('loading') + expect(consumer.at(-1)).toBe('active') + expect(sink.states.get('provider')?.at(-1)).toBe('active') + await ctx.fiber.dispose() + }) + + it('reports a row waiting on a service the roster never provides', async () => { + const graph = graphOf(['orphan']) + const { modules } = modulesOf(graph, { orphan: { inject: ['nothing'], apply: () => {} } }) + const ctx = new Context() + + await expect(bootClient({ ctx, modules, manifest: modules.manifest })).rejects.toThrow( + 'orphan: pending (waiting for service: nothing)', + ) + await ctx.fiber.dispose() + }) + + it('reports and logs an import failure for a row that is neither seeded nor a graph row', async () => { + const { modules } = modulesOf(graphOf(['seeded']), { seeded: { apply: () => {} } }) + const manifest = parseBootManifest(graphOf(['ghost'])) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + onTestFinished(() => { error.mockRestore() }) + const sink = stateSink() + + await expect(bootClient({ ctx, modules, manifest, onEntryState: sink.onEntryState })).rejects.toThrow( + 'web boot: 1 entry did not activate\nghost: import failed (see console for the import error)', + ) + expect(sink.states.get('ghost')).toEqual(['loading', 'failed']) + expect(error).toHaveBeenCalledOnce() + expect(error.mock.calls[0]?.[0]).toHaveProperty('message', expect.stringContaining('client-modules: cannot resolve')) + }) +}) + +describe('assertEntriesActive', () => { + interface FakeEntry { name: string; fiber?: { state: number; inject: Record } } + + /** Loader-shaped double: entries with scripted fiber states, services by name. */ + function auditCtx(entries: readonly FakeEntry[], services: Record = {}): Context { + return { + loader: { + * entries() { + for (const entry of entries) yield { options: { name: entry.name }, fiber: entry.fiber } + }, + }, + get: (name: string) => services[name], + } as unknown as Context + } + + it('passes when every entry is active', () => { + expect(() => { assertEntriesActive(auditCtx([{ name: 'a', fiber: { state: FIBER_STATE.ACTIVE, inject: {} } }])) }).not.toThrow() + }) + + it('names import failures, missing services, and other non-active states', () => { + const ctx = auditCtx([ + { name: 'lost' }, + { name: 'waiting', fiber: { state: FIBER_STATE.PENDING, inject: { present: null, a: null, b: null } } }, + { name: 'opaque', fiber: { state: FIBER_STATE.PENDING, inject: {} } }, + { name: 'broken', fiber: { state: FIBER_STATE.FAILED, inject: {} } }, + ], { present: {} }) + + expect(() => { assertEntriesActive(ctx) }).toThrow([ + 'web boot: 4 entries did not activate', + 'lost: import failed (see console for the import error)', + 'waiting: pending (waiting for services: a, b)', + 'opaque: pending (waiting for services: unknown)', + 'broken: failed', + ].join('\n')) + }) + + it('uses the singular form for one failing entry', () => { + expect(() => { assertEntriesActive(auditCtx([{ name: 'lost' }])) }).toThrow('web boot: 1 entry did not activate\n') + }) +}) diff --git a/packages/client/web/tests/mount.client.spec.ts b/packages/client/web/tests/mount.client.spec.ts new file mode 100644 index 0000000000..fbeb5635bd --- /dev/null +++ b/packages/client/web/tests/mount.client.spec.ts @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { mountClient } from '../src/mount.ts' + +/** Provide a fake `uiRenderer` from its own plugin fiber so it can be replaced. */ +function provideRenderer(ctx: Context, mount: (container: HTMLElement) => () => void) { + return ctx.plugin({ apply: (scope: Context) => { scope.reflect.provide('uiRenderer', { mount }) } }) +} + +describe('mountClient', () => { + it('mounts into the container and unmounts when the tree is disposed', async () => { + const ctx = new Context() + const unmount = vi.fn() + const mount = vi.fn((_container: HTMLElement) => unmount) + provideRenderer(ctx, mount) + const container = document.createElement('div') + + await mountClient(ctx, container) + + expect(mount).toHaveBeenCalledExactlyOnceWith(container) + expect(unmount).not.toHaveBeenCalled() + await ctx.fiber.dispose() + expect(unmount).toHaveBeenCalledOnce() + }) + + it('remounts when uiRenderer is replaced', async () => { + const ctx = new Context() + const container = document.createElement('div') + const first = { unmount: vi.fn(), mount: vi.fn(() => first.unmount) } + const second = { unmount: vi.fn(), mount: vi.fn(() => second.unmount) } + + await mountClient(ctx, container) + expect(first.mount).not.toHaveBeenCalled() + + const renderer = provideRenderer(ctx, first.mount) + await vi.waitFor(() => { expect(first.mount).toHaveBeenCalledExactlyOnceWith(container) }) + + await renderer.dispose() + expect(first.unmount).toHaveBeenCalledOnce() + + provideRenderer(ctx, second.mount) + await vi.waitFor(() => { expect(second.mount).toHaveBeenCalledExactlyOnceWith(container) }) + await ctx.fiber.dispose() + expect(second.unmount).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 5aba4c84cb..5f18cafa8b 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 59d4c303c7..7e31f4998e 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 6553c6b102..ac4c49b3b6 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/src/index.ts b/packages/compaction/command-compact/src/index.ts index 47657cf9b8..cd31b9e0a7 100644 --- a/packages/compaction/command-compact/src/index.ts +++ b/packages/compaction/command-compact/src/index.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import { ManualCompactionError } from '@deepseek-ai/dsh-compaction' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' @@ -98,6 +99,7 @@ export function apply(ctx: Context): void { // invocation can enter while already-started handler promises quiesce. yield async () => { await Promise.allSettled(active) } yield ctx.commands.register({ + definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-compact'), name: 'compact', description: 'Compact older conversation history', handler, diff --git a/packages/compaction/command-compact/tests/command-compact.spec.ts b/packages/compaction/command-compact/tests/command-compact.spec.ts index f2ab167d69..bd312d2a99 100644 --- a/packages/compaction/command-compact/tests/command-compact.spec.ts +++ b/packages/compaction/command-compact/tests/command-compact.spec.ts @@ -162,6 +162,7 @@ describe('@deepseek-ai/dsh-command-compact registration', () => { const loader = Object.create(Loader.prototype) as Loader expect(loader.unwrapExports(commandCompact)).toBe(commandCompact) expect(test.ctx.commands.list(test.agent)).toContainEqual({ + definitionId: '@deepseek-ai/dsh-command-compact', name: 'compact', description: 'Compact older conversation history', }) diff --git a/packages/compaction/command-compact/tests/loader-composition.spec.ts b/packages/compaction/command-compact/tests/loader-composition.spec.ts index 442d8196dc..57367b2c7b 100644 --- a/packages/compaction/command-compact/tests/loader-composition.spec.ts +++ b/packages/compaction/command-compact/tests/loader-composition.spec.ts @@ -120,6 +120,7 @@ describe('command-compact real Loader composition', () => { reserveTurnAdmission: () => () => undefined, } as unknown as Agent expect(context.commands.list(agent)).toContainEqual({ + definitionId: '@deepseek-ai/dsh-command-compact', name: 'compact', description: 'Compact older conversation history', }) diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 4e7d624403..82f980e727 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index 8bdecee0cf..e8efef2b12 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -98,7 +98,8 @@ interface TransactionFailure { */ function systemHead(session: Session, headSeq: SessionSeq): SessionEvent<'system/message'> | undefined { // Surface nodes are current log seqs, so the event exists. - // oxlint-disable-next-line typescript/no-non-null-assertion + // Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const head = session.eventAt(headSeq)! return head.type === 'system/message' ? head : undefined } @@ -536,7 +537,8 @@ function buildSummarizationInput( const system = head === undefined ? null : session.deriveEventMessage(head) const regionMessages = shadowedSeqs // shadowedSeqs are current surface seqs, so each is a valid log index. - // oxlint-disable-next-line typescript/no-non-null-assertion + // Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated .map(seq => session.deriveEventMessage(session.eventAt(seq)!)) .filter((message): message is Message => message !== null) return { @@ -553,7 +555,8 @@ function inspectCompactionEntryState(session: Session): CompactionEntryState { let compactionEntryStateKnown = false let latestEndSeedSeq: SessionSeq | undefined for (let seq = session.seq - 1; seq >= 0; seq -= 1) { - // oxlint-disable-next-line typescript/no-non-null-assertion + // Existing Session history read; migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const event = session.eventAt(SessionSeq(seq))! if (latestEndSeedSeq === undefined && event.type === 'session/end-seed') { latestEndSeedSeq = event.seq diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index caf335c1d3..93eedeba7f 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/src/index.ts b/packages/compaction/compaction-tool-result-pruner/src/index.ts index 18c752d8f1..6d8ef92298 100644 --- a/packages/compaction/compaction-tool-result-pruner/src/index.ts +++ b/packages/compaction/compaction-tool-result-pruner/src/index.ts @@ -136,6 +136,7 @@ export class ToolResultPruner extends Service { pruneSession(session: Session): PruneResult { const candidates: SnapshotCandidate[] = [] for (const seq of [...session.surface.nodes]) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(seq) /* v8 ignore next -- surface seqs are validated contiguous log references. */ if (event?.type === 'tool/result') candidates.push({ seq, event }) diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index a81efbc09a..4382613ca4 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/src/invariant.ts b/packages/compaction/compaction/src/invariant.ts index ce19a6670f..5682f066db 100644 --- a/packages/compaction/compaction/src/invariant.ts +++ b/packages/compaction/compaction/src/invariant.ts @@ -298,6 +298,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant surface: new SurfaceManager(surfaceEvents), } traces.set(session, trace) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() const staleOrphanStartSeqs = inheritedOrphanStartSeqs(events) for (const event of events) { diff --git a/packages/compaction/compaction/src/tool-pairing.ts b/packages/compaction/compaction/src/tool-pairing.ts index 56ec6a2368..ca070851ab 100644 --- a/packages/compaction/compaction/src/tool-pairing.ts +++ b/packages/compaction/compaction/src/tool-pairing.ts @@ -50,6 +50,7 @@ function extendCache( const pendingCuts: boolean[] = [] let inProgressToolCalls = cache.inProgressToolCalls for (const seq of tail) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(seq) if (event === undefined || event.seq !== seq) { throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`) diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 4f3075a46b..7fa0bba69e 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index f614926241..f775c30a9c 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -53,6 +53,7 @@ function visibleBaselineSource( } } for (const seq of agent.session.surface.nodes.toReversed()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(seq) if (event?.type === 'user/message' && event.data.source.kind === 'agent-instructions' @@ -228,6 +229,7 @@ export function apply(ctx: Context, config: Config): void { const alreadySupplied = desired !== undefined && ( claimed.some(message => sameContextPayload(message, desired)) || agent.session.surface.nodes.some((seq) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(seq) return event?.type === 'user/message' && sameContextPayload(event.data, desired) }) diff --git a/packages/context/agent-instructions/src/state.ts b/packages/context/agent-instructions/src/state.ts index fb25e262f9..8a66f7d02f 100644 --- a/packages/context/agent-instructions/src/state.ts +++ b/packages/context/agent-instructions/src/state.ts @@ -139,6 +139,7 @@ function visibleInstructionChanges( ): Map { const visible = new Map() for (const seq of agent.session.surface.nodes) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(seq) if (event?.type !== 'user/message' || !isWorkspaceContextSource(event.data.source)) continue const changes = workspaceInstructionChanges(event.data.source) diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 35133997f2..998c6e2e52 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 7b61041415..abc2a1cc2a 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 3273428e35..e4e17c536b 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index a27f801b3b..94023206a8 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 02cf81d738..f626342546 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -80,6 +80,7 @@ function formatDuration(elapsedMs: number): string { function requestMessages(agent: Agent, turn: number, proposed: readonly UserMessage[]): UserMessage[] { const entered: UserMessage[] = [] for (let seq = agent.session.seq - 1; seq >= 0; seq -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(SessionSeq(seq)) if (event?.type === 'turn/start' && event.data.turn === turn) { return [...entered.reverse(), ...proposed] diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 90ec5f2ff2..2fa686a4f4 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -161,6 +161,7 @@ function validateReading( /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ /** Validate all package-owned readings already present in one session. */ function validateSession(session: Session, fail: InvariantFailure): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() for (const [index, event] of events.entries()) { if (event.type !== 'user/message' @@ -180,6 +181,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (event.type !== 'user/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validateReading(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index aaab689106..95a04c31b9 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 296510d8ad..109ae3394d 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 986973d782..708c7c514a 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index aa967f87c0..6c30f04135 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -748,6 +748,7 @@ export class AgentLoop extends Service implements AgentFactory { */ private async appendUnstoredSuffix(stored: StoredSession | undefined, session: Session): Promise { if (stored === undefined) return + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(SessionLogOffset(stored.storedCount)) if (suffix.length > 0) await stored.handle.append(suffix) // Advance by what was stored, not to `session.seq`: an event appended diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index b1b2cc93c3..3b1c62d081 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -28,6 +28,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail('a loop-built request must carry a frozen messages array') } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() if (!events.some(event => event.type === 'step/start')) { return fail('a loop-built request with no step/start in its session log') diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index daabf5bc19..af9f3fa640 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -45,6 +45,7 @@ export interface SystemPromptDecisionInput { /** Committed events from the newest backward; the restore scans stop at the first match. */ function eventsNewestFirst(session: Session): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return session.snapshotEvents().toReversed() } @@ -63,6 +64,7 @@ export class SystemPromptProjection { private systemNodes(): { seq: SessionSeq; text: string | undefined }[] { const nodes: { seq: SessionSeq; text: string | undefined }[] = [] for (const seq of this.session.surface.nodes) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = this.session.eventAt(seq) if (event?.type !== 'system/message') continue const content = event.data.message.content diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index a7329fe690..10109f5ef1 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 031cdee0ad..b34895cb88 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 418572f660..3ac55af4cb 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index cd2a556c3a..e4ccbdc3ec 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 6ed688e475c0b1bc759d6ab8bd84e6537608bb40 -README.zh.md: fcadf48c048d8eba9a9dfb31a098eca27f2ac27b +README.md: a18fd0de73a6aa7dc881f1472d991c133f81ad55 +README.zh.md: 84cc0aa35da11a5a782e1949e32852785fb681c4 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 6ed688e475..a18fd0de73 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -55,7 +55,7 @@ Append, seed/restore, and event adoption/snapshot reject any `header.system` and ### Read the log -`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`. +`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. `eventAt()`, `snapshotEvents()`, and `ownEvents()` are deprecated: existing logic may remain unmigrated for now, but new production calls are prohibited. Repository test files may use these three readers under their scoped lint allowance ([policy](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Callers that only need a length use `seq`. Session log positions use two numeric types. `SessionSeq` identifies an existing event or inclusive event watermark; `SessionLogOffset` identifies a gap, prefix length, or read boundary and may equal the event count. `SessionSeqCursor` adds the `-1` “no event yet” value, while `OptionalSessionSeq` uses `null` when absence is data. The constructors validate non-negative safe integers, and the brands disappear at runtime, so durable JSON and wire values remain ordinary numbers. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index fcadf48c04..84cc0aa35d 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -55,7 +55,7 @@ session.deriveMessages() // the derived model history ### 读取日志 -`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。 +`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。`eventAt()`、`snapshotEvents()` 和 `ownEvents()` 已弃用:现有逻辑可以暂不迁移,但禁止新增生产调用。仓库测试文件可以在限定范围的 lint 豁免下使用这三个读取方法([策略](../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.zh.md))。只需要长度的调用方使用 `seq`。 会话日志位置使用两种数字类型。`SessionSeq` 标识已有事件或包含端点的事件水位;`SessionLogOffset` 标识间隙、前缀长度或读取边界,并且可以等于事件数量。`SessionSeqCursor` 添加 `-1` 这个“尚无事件”值,`OptionalSessionSeq` 则在缺失本身属于数据时使用 `null`。构造函数会校验非负安全整数,brand 在运行时会被擦除,因此持久 JSON 与 wire 值仍是普通数字。 diff --git a/packages/core/session/package.json b/packages/core/session/package.json index f840ae6136..4f43ac08dd 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9db8327409..12e9ed1918 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -615,6 +615,8 @@ export class Session { /** * Return the immutable event stored at one exact sequence number. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param seq - event sequence number. * @returns the accepted event, or undefined when the log does not contain it. */ @@ -626,6 +628,8 @@ export class Session { * Materialize an immutable snapshot of a half-open event sequence range. * A full current snapshot is reused until the next append; every previously * returned snapshot remains stable after later appends. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @param fromSeq - non-negative inclusive sequence number; defaults to the log start. * @param toSeqExclusive - non-negative exclusive sequence number; defaults to the current end. * @returns a frozen array of the selected deeply frozen events. @@ -643,9 +647,12 @@ export class Session { /** * Return this Session's events after its fork-inherited prefix. + * @deprecated Existing logic may remain unmigrated for now, but new calls are prohibited. + * See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md). * @returns a fresh array containing child-owned events in log order. */ ownEvents(): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Deprecated reader delegates to the deprecated range read. return this.snapshotEvents(this.inheritedEventCount) } @@ -1211,6 +1218,7 @@ export class SessionStore extends Service { } private _forkSeed(session: Session, requestedBoundary: SessionSeq | undefined): readonly SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const lastEvent = session.snapshotEvents().at(-1) let boundary: SessionSeq if (requestedBoundary !== undefined) { @@ -1233,6 +1241,7 @@ export class SessionStore extends Service { ) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const boundaryEvent = session.eventAt(boundary) if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) { throw new SessionForkError( @@ -1240,6 +1249,7 @@ export class SessionStore extends Service { 'INVALID_BOUNDARY', ) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1)) const lastTurnBoundary = events .findLast(event => event.type === 'turn/start' || event.type === 'turn/end') diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 6ed0b6b3c5..be4b214c86 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -210,6 +210,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seedSession = (session: Session): SessionTrace => { const trace = freshTrace() traces.set(session, trace) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { applyTransition(trace, validateEvent(trace, event, fail)) } diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 1fceb2e641..f9c9e63d0b 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: 7e4826c4fb51873db02fd80eca632c3d1dd8d550 -README.zh.md: 30f2a43908513f5fe1e7169bff7ee95c9461ea5d +README.md: a76ae5c66f0801cb85cd8cf22d0c3ebbc68e65b8 +README.zh.md: 98c6e733399c954c5bbc56b905aefcc6b080381d diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 7e4826c4fb..a76ae5c66f 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -63,6 +63,8 @@ ctx.systemPrompt.section({ }) ``` +Set `interpolate: false` on a section to preserve its text literally, including `{{…}}` groups in generated tool documentation. Other sections interpolate variables by default. + ### Contribute a prompt variable Variables are referenced from section text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `model` and `cwd`; any plugin can register the facts it owns. @@ -102,7 +104,7 @@ The package is a registry plus a cooperative assembly pipeline. One `assemble()` ### Assembly and rendering -Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits. +Assembly resolves and renders in two stages: `assemble()` returns sections with resolved-but-uninterpolated text, the ordered tool schemas, and every registered variable resolved against the context, while `renderPrompt()` interpolates `{{variable}}` references unless a section sets `interpolate: false`, drops empty sections, and joins with blank lines — strictly, an unknown reference, a registered-but-valueless reference, or a malformed complete group throws, because a malformed prompt is worse than a loud failure. `toolOrder` canonicalizes the collected tools before the waterfall (registration order is a plugin-load artifact); a waterfall listener that mutates the list owns the determinism of what it emits. ### Scoping @@ -170,7 +172,7 @@ Prefix-stable while the visible schema set, rendering, and order are unchanged. These limits define when prompt assembly needs special care. They are current package constraints, not a task backlog. - **Deployment-authored prompt text is config/composition only** — this plugin owns the global persona prefix and suffix defaults, creator plugins may register agent-scoped shadows, and other sections come from the plugin that owns the fact; there is no end-user prompt-editing API. -- **No escape syntax for literal `{{…}}` braces** — every complete group is interpolated against registered variables; an escape is deferred until a real prompt needs one. +- **No inline escape syntax in interpolated text** — use `interpolate: false` when a whole section must preserve literal braces. - **`toolOrder` misconfiguration surfaces at prompt assembly (the first turn), not at boot** — only shape violations throw at config load. diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 30f2a43908..98c6e73339 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -63,6 +63,8 @@ ctx.systemPrompt.section({ }) ``` +在段上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档中的 `{{…}}` 组。其他段默认执行变量插值。 + ### 贡献提示词变量 变量在段文本中以 `{{name}}` 引用,并在每次组装时解析;带作用域变量会为该 agent 遮蔽同名全局变量。循环提供 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实。 @@ -102,7 +104,7 @@ ctx.systemPrompt.variable('cwd', ({ agent }) => agent?.session.header.cwd) ### 组装与渲染 -组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。 +组装分两阶段完成求值与渲染:`assemble()` 返回文本已求值但尚未插值的段、有序工具 schema,以及每个已注册变量按当前上下文求得的值;`renderPrompt()` 对未设置 `interpolate: false` 的段插值 `{{variable}}` 引用、删除空段并用空行连接——严格规则:未知引用、已注册但无值的引用或格式错误的完整组都会抛出,因为格式错误的提示词比明确失败更糟。`toolOrder` 在 waterfall 分发前规范化收集到的工具(注册顺序只是插件加载产物);修改列表的 waterfall 监听器对其输出的确定性负责。 ### 作用域 @@ -170,7 +172,7 @@ schema token 在每次请求中重复。限制工具会为该 agent 移除其全 这些限制说明提示词组装何时需要特别留意。它们是当前包约束,不是待办事项清单。 - **部署方编写的提示词文本只来自配置/组合**:此插件拥有全局 persona 前缀与后缀默认值;创建方插件可以注册 agent 作用域的遮蔽项;其他段来自拥有相应事实的插件。不存在终端用户提示词编辑 API。 -- **没有表示字面量 `{{…}}` 花括号的转义语法**:每个完整组都会按已注册变量插值;只有实际提示词需要转义时才会实现。 +- **插值文本不支持行内转义语法**:整段需要保留字面花括号时,使用 `interpolate: false`。 - **`toolOrder` 配置错误在提示词组装(首轮)时出现,而不是启动时**:只有形状违规会在配置加载时抛出。 diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 0a5a6deca2..0df2d6de53 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index d83cc4d216..0d056ec1fc 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -61,9 +61,11 @@ export interface PromptSection { /** * Static text or a provider evaluated at each assembly with that assembly's * {@link AssembleContext}. The text may reference `{{variable}}`s — they are - * interpolated later, by {@link renderPrompt}. + * interpolated later, by {@link renderPrompt}, unless `interpolate` is false. */ readonly text: string | ((context: AssembleContext) => string) + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + readonly interpolate?: boolean /** * Treat this contribution as the complete system prompt. Assembly still * runs the cooperative waterfall so tools, contexts, and variables can be @@ -89,6 +91,8 @@ export interface AssembledSection { name: string /** The resolved (but not yet interpolated) section text. */ text: string + /** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */ + interpolate?: boolean } /** One resolved dynamic context contribution. */ @@ -264,7 +268,8 @@ export interface Config { /** * Interpolate strict `{{variable}}` references, drop empty sections, and join - * the rest with blank lines. Malformed, unknown, or undefined references throw; + * the rest with blank lines. Sections with `interpolate: false` retain literal + * text. Malformed, unknown, or undefined references in other sections throw; * a lone `{{` without any later `}}` is literal prose, and substituted values * are not scanned again. * @param assembly - the assembly whose sections and variables to render. @@ -272,7 +277,7 @@ export interface Config { */ export function renderPrompt(assembly: PromptAssembly): string { return assembly.sections - .map(section => interpolate(section, assembly.variables, 'section')) + .map(section => section.interpolate === false ? section.text : interpolate(section, assembly.variables, 'section')) .filter(text => text.length > 0) .join('\n\n') } @@ -597,6 +602,7 @@ export class SystemPrompt extends Service { const assembled = { name: section.name, text: typeof section.text === 'function' ? section.text(context) : section.text, + ...section.interpolate !== undefined ? { interpolate: section.interpolate } : {}, } if (section.complete === true) completeSection = { ...assembled } return assembled diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 8e60ea2bec..1d73203062 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -579,6 +579,25 @@ describe('SystemPrompt', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toBe(`${IDENTITY}\n\nYou run on deepseek-v4 in /work.`) }) + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])('preserves literal section text with complete=%s and dynamic=%s', async (complete, dynamic) => { + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt, { includeHarnessIdentity: false, personaPrefix: '{{model}}' }) + ctx.systemPrompt.variable('model', () => 'actual-model') + const text = '{{item}} {{model}} {{ model }} {{nested{{item}}}}' + ctx.systemPrompt.section({ name: 'literal', order: 1, text: dynamic ? () => text : text, interpolate: false, complete }) + expect(renderPrompt(await ctx.systemPrompt.assemble())) + .toBe(complete ? text : `actual-model\n\n${text}`) + } finally { + await ctx.fiber.dispose() + } + }) + it('lets a waterfall listener add or override variables before render', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 25e4af6004..d4db7dca51 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: a62e89c1bde0b96d5ba6b0e7c8f27d2af87f4340 -README.zh.md: d10ce3913cc9eb24e417f61c5b9f081a40b50cc3 +README.md: 17e2eb05bac152c75f7a906bf6e15f26517d38dc +README.zh.md: cd08e9282e2ac6e75c1694d9989a4079d65985b5 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a62e89c1bd..17e2eb05ba 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -170,7 +170,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. +PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. The TypeScript instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. ##### TypeScript PTC mode SDK instructions with bash diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index d10ce3913c..cd08e9282e 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -170,7 +170,7 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 +PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 ##### 带 bash 的 TypeScript PTC mode SDK 说明 diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 8aa41f993f..e3d6cdc3b8 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index bef11baf26..ccfb594774 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -13,7 +13,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { UserMessage } from '@deepseek-ai/dsh-session' import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' +import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' // Type-only: makes `ctx.get('approval')` resolve to the ApprovalService // augmentation. The seam stays optional at runtime — see `serviceAsk`. @@ -844,7 +844,7 @@ export class ToolRuntime extends Service { * `both` renders empty: native calls do execute there, so the rule is false. * @returns the section registration. */ - private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + private collapseSection(): PromptSection { return { name: 'tools:ptc-only', order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'), @@ -864,10 +864,11 @@ export class ToolRuntime extends Service { * dropped from the rendered prompt. * @returns the section registration. */ - private sdkSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { + private sdkSection(): PromptSection { return { name: 'tools:sdk', order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), + interpolate: false, // Regenerate from the calling scope's visible tools in stable order. text: (context) => { const mode = this.modeFor(context.scope) diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index 2fb5b5878c..d5edf99697 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -58,6 +58,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): number | null => { let openTurn: number | null = null dispatchRoots.set(session, new Map()) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { validateDispatch(session, event) commitDispatch(session, event) diff --git a/packages/core/tools/tests/fixtures/literal-sdk.ts b/packages/core/tools/tests/fixtures/literal-sdk.ts new file mode 100644 index 0000000000..4f0206456f --- /dev/null +++ b/packages/core/tools/tests/fixtures/literal-sdk.ts @@ -0,0 +1,20 @@ +/** Tool documentation containing literal template syntax for recorded PTC replay. */ + +import type { Context } from '@deepseek-ai/cordis' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'literal-sdk' +export const inject = ['tools'] + +export function apply(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'template_echo', + description: 'Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution.', + parameters: { text: { type: 'string', required: true } }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: args => Promise.resolve(args.text), + })) +} diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 11bf14e01a..2ebd121f0c 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' @@ -117,6 +117,38 @@ async function runCode( } describe('mode-aware wire contribution', () => { + it.each([ + { mode: 'ptc', language: 'typescript' }, + { mode: 'both', language: 'typescript' }, + { mode: 'ptc', language: 'python' }, + { mode: 'both', language: 'python' }, + ] as const)('preserves literal braces in the $language SDK under $mode', async ({ mode, language }) => { + const { ctx, systemPrompt } = await setup({ mode, runtime: { language } }) + try { + systemPrompt.variable('model', () => 'actual-model') + const description = 'Expand {{item}} with {{model}} or {{ model }}.' + ctx.tools.register(defineTool({ + name: 'template', + description, + parameters: { value: { type: 'string', description, enum: ['{{item}}', '{{model}}'], required: true } }, + output: { + schema: { type: 'string', enum: ['{{item}}', '{{model}}'] }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute: args => Promise.resolve(args.value), + })) + const assembly = await systemPrompt.assemble() + const sdk = assembly.sections.find(section => section.name === 'tools:sdk') + expect(sdk).toBeDefined() + const prompt = renderPrompt(assembly) + expect(prompt).toContain(description) + expect(prompt).toContain(sdk!.text) + expect(prompt).not.toContain('actual-model') + } finally { + await ctx.fiber.dispose() + } + }) + it("mode 'native' contributes every schema, no run_code, no SDK section — and needs no runtime", async () => { const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) registerEcho(ctx) diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index d7c3190037..830bdf51fe 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index cceff4b390..c33473e37f 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 4d2fab8274..2b442b47c8 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index bfe05b0245..9a2bd625fc 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 5629ae184b..4381fe7e86 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 9ab4c2c954..f5824b362c 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index d35803a238..51fefe782d 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Experimental profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index fd41141b51..e4df1779f9 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Experimental Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index fd174f04c9..b61057e3aa 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team/src/mailbox.ts b/packages/experimental/agent-team/src/mailbox.ts index acd842308a..6afc7f441b 100644 --- a/packages/experimental/agent-team/src/mailbox.ts +++ b/packages/experimental/agent-team/src/mailbox.ts @@ -299,6 +299,7 @@ export class TeamMailbox { /** Whether a target Session already contains the durable message identity. */ private targetRecorded(session: Session, messageId: TeamMessageId): boolean { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(session.inheritedEventCount) return messageAccepted(suffix, message => message.source.kind === 'team-message' && message.source.messageId === messageId) diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index 3d0ca8372e..ae52c741c2 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -375,6 +375,7 @@ export class TeamRoster { try { signal.throwIfAborted() await this.ctx.sessions.flush(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(session.inheritedEventCount) if (messageAccepted(suffix, message => message.id === messageId)) return if (this.ctx.sessions.get(childId) !== session) continue @@ -482,6 +483,7 @@ export class TeamRoster { /** Whether a Session's own suffix identifies a provider-owned subagent child. */ private subagentDescriptor(agent: Agent): boolean { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.inheritedEventCount)) !== undefined } } diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index d090fd6832..4932c980ba 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index fa589196f1..2fb430b5a8 100644 --- a/packages/experimental/code-runtime-python/package.json +++ b/packages/experimental/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/code-runtime-python/tests/runtime.spec.ts b/packages/experimental/code-runtime-python/tests/runtime.spec.ts index 4fa4f225d4..287cf8f785 100644 --- a/packages/experimental/code-runtime-python/tests/runtime.spec.ts +++ b/packages/experimental/code-runtime-python/tests/runtime.spec.ts @@ -1494,93 +1494,6 @@ describe('PythonCodeRuntime — programs and bindings', () => { await fiber.dispose() }) - it('bounds an illegal-UTF-8 native residual by its U+FFFD-decoded cost', async () => { - // Every 0xFF byte is illegal in any UTF-8 sequence, so `toString('utf8')` - // renders each as U+FFFD (3 serialized bytes). `accrueStrayCost` must charge - // that 3, not the raw 1: otherwise the newline-free residual grows to a full - // budget's worth of RAW bytes before flushing — a ~3x undercount that near a - // large maxLogBytes retains hundreds of MiB then expands toward a ~1 GiB peak - // in flushStray's concat + toString. Paced single-byte writes (each its own - // `data` chunk, like the sealing case) expose the sub-chunk accrual: charged - // at 3 the residual crosses a 3072-byte budget after ~1024 bytes and flushes; - // charged at 1 it would need ~3072 bytes, so the peak residual triples. The - // largest merged buffer is the discriminator. - const realConcat = Buffer.concat.bind(Buffer) - let maxConcat = 0 - Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { - const merged = realConcat(list, total) - if (merged.length > maxConcat) maxConcat = merged.length - return merged - } - let result: CodeRunResult - try { - const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) - result = await runtime.run({ - program: [ - 'import os', - 'for _ in range(6000):', - ' os.write(1, b"\\xff")', - ' os.sched_yield()', - 'return None', - ].join('\n'), - bindings: [], - }) - } finally { - Buffer.concat = realConcat - } - expect(result.error).toBeUndefined() - expect(result.logs.at(-1)).toBe(logTruncationMarker(3072)) - // Charged at 3, the residual flushes around 1024 raw bytes; the largest - // merged buffer stays well under 2048. A raw-byte undercount would let it - // reach ~3072 before flushing, so 2048 discriminates. - expect(maxConcat).toBeLessThan(2048) - }) - - it('charges a structurally-valid but illegal UTF-8 sequence its U+FFFD-decoded cost', async () => { - // A CESU-8 lone surrogate `ED A0 80` is structurally well-formed (a 3-byte - // lead plus two 0x80–0xBF continuations) but ILLEGAL: `toString('utf8')` - // renders each of the three bytes as its own U+FFFD (serialized cost 9), not - // one width-3 character. The newline-free flush trigger weighs the residual - // through `accrueStrayCost`, which must validate each lead's - // first-continuation range (ED excludes A0–BF) and charge the true 9 — else a - // CESU flood undercounts 3x and the residual grows toward a full budget's raw - // bytes before flushing, the same peak-memory vector as the 0xFF case. The - // bytes are written one at a time (each its own `data` chunk, no pipe - // coalescing) and `Buffer.concat` is wrapped to measure the peak residual. - const realConcat = Buffer.concat.bind(Buffer) - let maxConcat = 0 - Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { - const merged = realConcat(list, total) - if (merged.length > maxConcat) maxConcat = merged.length - return merged - } - let result: CodeRunResult - try { - const { runtime } = await setup({ maxLogBytes: 3072, maxWallMs: 30_000 }) - result = await runtime.run({ - program: [ - 'import os', - 'seq = (0xed, 0xa0, 0x80)', - 'for _ in range(2000):', - ' for b in seq:', - ' os.write(1, bytes((b,)))', - ' os.sched_yield()', - 'return None', - ].join('\n'), - bindings: [], - }) - } finally { - Buffer.concat = realConcat - } - expect(result.error).toBeUndefined() - expect(result.logs.at(-1)).toBe(logTruncationMarker(3072)) - // Each 3-byte sequence costs 9 (three U+FFFD), so single-byte-paced the - // residual crosses the 3072 budget after ~342 raw bytes and flushes; the - // largest merged buffer stays well under 2048. Charging the structural width - // 3 would need ~1024 raw bytes, tripling the peak past 2048. - expect(maxConcat).toBeLessThan(2048) - }) - it('charges a lone surrogate its full six escaped bytes, not three', async () => { // A forged `log` frame carrying `\ud800` escapes materializes lone // surrogates after JSON.parse. `Buffer.byteLength` of U+FFFD is 3, but @@ -4746,58 +4659,6 @@ describe('PythonCodeRuntime — hostile peer', () => { expect(copied).toBeLessThan(256 * 1024) }, 40_000) - it('seals trickled stray fragments into blocks without recopying the sealed prefix', async () => { - // The stray-capture buffer has the same object-overhead exposure as the fd-3 - // reader above: each newline-free `data` chunk is its own Buffer, so a - // program pacing single-byte `os.write(1, ...)` accumulates one object per - // write, which the serialized-cost counter cannot see. Past MAX_PENDING_CHUNKS - // the fragments seal into a finished block; re-merging the whole residual at - // each threshold instead would copy the sealed prefix again and again, making - // the cumulative copy volume quadratic. `Buffer.concat` is wrapped to measure - // that volume — both shapes admit the same final log entry, so the copy total - // is the discriminator. maxLogBytes is raised so the trickle is retained, - // not truncated, which is what forces the fragments to accumulate and seal. - const realConcat = Buffer.concat.bind(Buffer) - let copied = 0 - Buffer.concat = (list: readonly Uint8Array[], total?: number): Buffer => { - for (const part of list) copied += part.length - return realConcat(list, total) - } - let result: CodeRunResult - try { - const { runtime } = await setup({ maxLogBytes: 200_000, maxWallMs: 30_000 }) - result = await runtime.run({ - program: [ - 'import os', - 'for _ in range(60000):', - ' os.write(1, b"x")', - ' os.sched_yield()', - 'os.write(1, b"\\n")', - 'return "done"', - ].join('\n'), - bindings: [], - }) - } finally { - Buffer.concat = realConcat - } - expect(result.error).toBeUndefined() - expect(result.value).toBe('done') - // The trickle coalesces into one log line (no interior newlines). Its exact - // length depends on pipe coalescing, but it is one entry and non-empty. - expect(result.logs.length).toBe(1) - expect((result.logs[0] as string).length).toBeGreaterThan(0) - // Sealing appends a finished block rather than re-merging everything held, so - // each byte is copied a bounded number of times. Re-merging the whole - // residual at every seal threshold instead makes the cumulative copy volume - // quadratic. Measured like the fd-3 sibling above rather than reasoned about: - // this sealed shape copies about 120 KB for 60000 trickled bytes, the - // re-merging shape about 538 KB (the stray path adds one whole-residual - // concat at the terminating newline over the fd-3 sibling's 119/540, landing - // at the same order). 256 KiB sits between them with margin on both sides, so - // reverting the seal to a re-merge turns this assertion red. - expect(copied).toBeLessThan(256 * 1024) - }, 40_000) - it('caps a huge exception diagnostic child-side before it crosses the wire', async () => { // A program can raise with a multi-megabyte message; the child must cap // it at maxValueBytes before formatting/sending, not ship the whole diff --git a/packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts b/packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts new file mode 100644 index 0000000000..500082b238 --- /dev/null +++ b/packages/experimental/code-runtime-python/tests/stray-fragments.spec.ts @@ -0,0 +1,87 @@ +import { Context } from '@deepseek-ai/cordis' +import { expect, it, vi } from 'vitest' +import { logTruncationMarker } from '../src/protocol.ts' + +// Keep the interpreter and pipe lifecycle real; only OS-dependent read sizes +// change. Each byte reaches the runtime as its own data event. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawn: vi.fn(actual.spawn).mockImplementation((...args) => { + const child = actual.spawn(...args) + const stdout = child.stdout! + const emit = stdout.emit.bind(stdout) + stdout.emit = (event: string | symbol, ...values: unknown[]) => { + if (event !== 'data') return emit(event, ...values) + const chunk = values[0] as Buffer + for (let offset = 0; offset < chunk.length; offset++) { + emit('data', chunk.subarray(offset, offset + 1)) + } + return true + } + return child + }), + } +}) + +const { PythonCodeRuntime } = await import('../src/index.ts') + +it('seals stray fragments without recopying the sealed prefix', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(PythonCodeRuntime, { maxLogBytes: 200_000, maxWallMs: 30_000 }) + const realConcat = Buffer.concat.bind(Buffer) + let copied = 0 + let maxFragments = 0 + const concat = vi.spyOn(Buffer, 'concat').mockImplementation((list, total) => { + maxFragments = Math.max(maxFragments, list.length) + for (const part of list) copied += part.length + return realConcat(list, total) + }) + try { + const result = await ctx.codeRuntime.run({ + program: 'import os\nos.write(1, b"x" * 60000 + b"\\n")\nreturn "done"', + bindings: [], + }) + expect(result.error).toBeUndefined() + expect(result.value).toBe('done') + expect(result.logs).toEqual(['x'.repeat(60_000)]) + // Sealing copies each byte at most twice; merging every accumulated prefix + // instead copies over a megabyte for these 60,001 controlled fragments. + expect(maxFragments).toBeLessThanOrEqual(1024) + expect(copied).toBeLessThan(256 * 1024) + } finally { + concat.mockRestore() + await fiber.dispose() + } +}, 40_000) + +it.each([ + { name: 'illegal UTF-8 bytes', payload: 'b"\\xff" * 3200' }, + { name: 'CESU-8 lone surrogates', payload: 'b"\\xed\\xa0\\x80" * 1100' }, +])('bounds $name by their U+FFFD-decoded cost', async ({ payload }) => { + const ctx = new Context() + const fiber = await ctx.plugin(PythonCodeRuntime, { maxLogBytes: 3072, maxWallMs: 30_000 }) + const realConcat = Buffer.concat.bind(Buffer) + let maxConcat = 0 + const concat = vi.spyOn(Buffer, 'concat').mockImplementation((list, total) => { + const merged = realConcat(list, total) + maxConcat = Math.max(maxConcat, merged.length) + return merged + }) + try { + const result = await ctx.codeRuntime.run({ + program: `import os\nos.write(1, ${payload})\nreturn None`, + bindings: [], + }) + expect(result.error).toBeUndefined() + expect(result.logs.at(-1)).toBe(logTruncationMarker(3072)) + // Each raw byte decodes to U+FFFD (three UTF-8 bytes), so a 3072-byte + // budget flushes near 1024 raw bytes. Charging raw or structural widths + // instead retains over 2048 bytes before flushing these payloads. + expect(maxConcat).toBeLessThan(2048) + } finally { + concat.mockRestore() + await fiber.dispose() + } +}, 20_000) diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 09fe122bd9..b7983de638 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 46bbad99be..9cabf89f48 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -2,10 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import WebSocket, { type RawData } from 'ws' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type TestContext } from 'vitest' import { CordisTreeCollector } from '../src/shared/cordis/collector.ts' import { observeCordisTree } from '../src/shared/cordis/observer.ts' -import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import * as inspectorBridge from '../src/host/bridge/controller.ts' +import type { InspectorHandle, InspectorOptions } from '../src/host/bridge/controller.ts' import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts' import { inspectorId } from '../src/shared/bridge/ids.ts' @@ -76,6 +77,19 @@ class CdpClient { } } +/** Own pending startup through cancellation; tree assertions do not measure Worker cold-start latency. */ +async function startTestInspector(options: InspectorOptions, test: TestContext): Promise { + const pending = inspectorBridge.startInspector({ ...options, startupTimeoutMs: test.task.timeout }) + test.onTestFinished(async () => { + // Failed starts terminate their Worker before rejecting. + const [started] = await Promise.allSettled([pending]) + if (started.status === 'fulfilled') await started.value.close() + }) + const handle = await pending + test.signal.throwIfAborted() + return handle +} + describe('Cordis tree inspection', () => { let inspector: InspectorHandle | undefined let cdp: CdpClient | undefined @@ -84,6 +98,48 @@ describe('Cordis tree inspection', () => { const observers: Array<() => void> = [] const fibers: Array<{ dispose(): Promise }> = [] + it('closes a late-starting Worker after test cancellation without returning its handle', async (test) => { + const release = Promise.withResolvers() + const controller = new AbortController() + const cleanups: Array[0]> = [] + let starting: Promise | undefined + const start = inspectorBridge.startInspector + const spy = vi.spyOn(inspectorBridge, 'startInspector').mockImplementation(async (options) => { + starting = start(options) + const handle = await starting + await release.promise + return handle + }) + test.onTestFinished(async () => { + release.resolve(undefined) + spy.mockRestore() + if (starting === undefined) return + const [started] = await Promise.allSettled([starting]) + if (started.status === 'fulfilled') await started.value.close() + }) + const pending = startTestInspector({ port: 0, captureFetch: false }, { + ...test, + signal: controller.signal, + onTestFinished: (cleanup) => { cleanups.push(cleanup) }, + }) + const result = pending.then( + handle => ({ status: 'fulfilled' as const, handle }), + (reason: unknown) => ({ status: 'rejected' as const, reason }), + ) + if (starting === undefined) throw new Error('Inspector startup was not called') + const handle = await starting + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ startupTimeoutMs: test.task.timeout })) + expect(cleanups).toHaveLength(1) + const cancelled = new Error('test cancelled during startup') + controller.abort(cancelled) + release.resolve(undefined) + const outcome = await result + expect(outcome.status).toBe('rejected') + expect('reason' in outcome ? outcome.reason : undefined).toBe(cancelled) + await cleanups[0]!(test) + await expect(fetch(handle.endpoint.httpUrl)).rejects.toThrow() + }) + afterEach(async () => { for (const dispose of observers.splice(0).reverse()) dispose() for (const fiber of fibers.splice(0).reverse()) await fiber.dispose() @@ -362,8 +418,8 @@ describe('Cordis tree inspection', () => { backend.close() }) - it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => { - inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }, test) const host = new Context() const hostFiber = host.plugin({ name: 'host-child', apply() {} }) fibers.push(hostFiber) @@ -540,8 +596,8 @@ describe('Cordis tree inspection', () => { expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected') }) - it('emits only node-level DOM changes for Client snapshots', async () => { - inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + it('emits only node-level DOM changes for Client snapshots', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }, test) cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) const initialDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode const clientsNode = initialDocument.children?.find(node => node.localName === 'clients') @@ -598,8 +654,8 @@ describe('Cordis tree inspection', () => { }) }) - it('serves three document levels by default and withheld levels on demand', async () => { - inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + it('serves three document levels by default and withheld levels on demand', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }, test) const host = new Context() let innerFiber: { uid: number | null } | undefined const outer = host.plugin({ @@ -680,14 +736,14 @@ describe('Cordis tree inspection', () => { expect(secondCdp.events.slice(offset).some(event => event.method === 'DOM.setChildNodes')).toBe(false) }) - it('restores a disconnected Client tree from a new transport generation', async () => { - inspector = await startInspector({ + it('restores a disconnected Client tree from a new transport generation', async (test) => { + inspector = await startTestInspector({ port: 0, captureFetch: false, maxCordisNodes: 100, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20, - }) + }, test) clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Reconnect Client' }) cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) await cdp.call('Runtime.enable') diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index d5b6600431..be33c259a2 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index be4f8a0476..22332423eb 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index ea8de3d587..7abcc672c9 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md -README.md: 67604adeb60f83953bc7c6841aa5d3d85ca38448 -README.zh.md: 985345efc3bc4fe63226a3e76b3432fa4bde06fa +README.md: d6291063678c9b9f6cc233ce91d737ffe187c37c +README.zh.md: 5c3a54a054284ecd2ba227913099533a41fd2605 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 67604adeb6..d629106367 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -32,7 +32,7 @@ Three build artifacts and one source-owned process layer: Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The fixture generator owns current-generation logs and the projection cache; committed predecessor logs remain byte-identical alongside them. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. -The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts only Node’s unknown-extension error for the expected stylesheet; other failures and unexpectedly successful exempt imports remain errors. See the [CI observation decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). +The [built-bundle import sweep](tests/compile/transform-corpus-check.ts) checks bare Node imports after the library build. Its Dockkit exception accepts Node’s unknown-`.css`-extension refusal for any stylesheet rather than one pinned path; another extension, error code, or message, and an unexpectedly successful exempt import, remain errors. See the [stylesheet exemption decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.md). ----- diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index 985345efc3..5c3a54a054 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -32,7 +32,7 @@ kind: "package-library" 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。fixture 生成器负责当前代日志与投影缓存;已提交的前代日志逐字节保持不变,与它们并存。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 -[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外仅接受 Node 针对预期样式表报告的未知扩展名错误;其他失败以及意外成功的豁免导入仍然报错。参见 [CI 观察决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 +[已构建 bundle 导入检查](tests/compile/transform-corpus-check.ts)在库构建后检查裸 Node 导入。Dockkit 例外接受 Node 针对任意样式表报告的未知 `.css` 扩展名错误,而不限定某一固定路径;其他扩展名、错误码或消息,以及意外成功的豁免导入,仍然报错。参见 [样式表豁免决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-built-bundle-css-exemption.zh.md)。 ----- diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index cbeb482553..ec646a9b60 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts index 377166ee1b..4139846db2 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus-check.ts @@ -24,7 +24,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url' const repositoryRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) const DOCKKIT_BUNDLE = 'packages/client/ui-dockkit/lib/index.js' -const DOCKKIT_CSS = join(repositoryRoot, 'packages/client/ui-dockkit/lib/components/dockkit.module.css') /** * Files Node's ESM loader cannot import in this repository. None is a finding: @@ -34,6 +33,11 @@ const DOCKKIT_CSS = join(repositoryRoot, 'packages/client/ui-dockkit/lib/compone * imports the win32-process package earlier in the serial sweep (a distinct * module instance under its node_modules URL), so win32-process's own file-URL * import re-registers koffi's type names and fails as the second load. + * + * The Dockkit entry admits only Node's refusal of a `.css` import, not one + * exact stylesheet: the swept bundle's first unresolvable stylesheet depends on + * its import graph and on how the launcher resolves workspace packages, so a + * pinned path would certify an import order instead of the `.css` exemption. */ const BASELINE_EXEMPT: ReadonlyMap = new Map([ [DOCKKIT_BUNDLE, 'imports .css, which bare Node cannot load'], @@ -95,6 +99,16 @@ function discover(): string[] { */ const relative = (path: string): string => path.slice(repositoryRoot.length).replaceAll('\\', '/') +/** + * @param reason - the value the corpus import rejected with. + * @returns whether bare Node refused a `.css` import, the failure the Dockkit + * exemption admits. + */ +const isUnknownCssExtension = (reason: unknown): boolean => + reason instanceof Error + && 'code' in reason && reason.code === 'ERR_UNKNOWN_FILE_EXTENSION' + && reason.message.startsWith('Unknown file extension ".css" for ') + const files = process.argv.slice(2).length > 0 ? process.argv.slice(2).map(path => (path.startsWith('/') ? path : join(process.cwd(), path))) : discover() @@ -111,10 +125,7 @@ if (files.length === 0) { try { await import(pathToFileURL(file).href) } catch (reason) { - const expectedDockkitCss = reason instanceof Error - && 'code' in reason && reason.code === 'ERR_UNKNOWN_FILE_EXTENSION' - && reason.message === `Unknown file extension ".css" for ${DOCKKIT_CSS}` - if (exemption === undefined || (key === DOCKKIT_BUNDLE && !expectedDockkitCss)) { + if (exemption === undefined || (key === DOCKKIT_BUNDLE && !isUnknownCssExtension(reason))) { // A bundle that stopped being importable is a real finding, so it // fails rather than joining a tolerated total. fail(`- UNEXPECTED BASELINE FAILURE ${key}: ${(reason as Error).message.split('\n')[0]}`) diff --git a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts index 8f8819b454..e09ca108e0 100644 --- a/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts +++ b/packages/experimental/webworker-runtime/tests/compile/transform-corpus.spec.ts @@ -32,19 +32,29 @@ test('every built bundle imports under Node', (context) => { }, 900_000) // The hook replaces only the chosen bundle; shared build artifacts stay intact. +// The admitted evidence is Node's unknown-`.css`-extension refusal whatever +// stylesheet it names; another extension, error code, or message is a finding. test.each([ ['expected-css', 0, 'baselineExempt=1 unexpectedBaselineFailure=0'], + ['other-css', 0, 'baselineExempt=1 unexpectedBaselineFailure=0'], + ['other-extension', 1, '- UNEXPECTED BASELINE FAILURE'], ['error', 1, '- UNEXPECTED BASELINE FAILURE'], - ['other-css', 1, '- UNEXPECTED BASELINE FAILURE'], ['other-code', 1, '- UNEXPECTED BASELINE FAILURE'], ['clean', 1, '- STALE EXEMPTION'], ] as const)('classifies dockkit import: %s', (mode, status, finding) => { const root = new URL('../../../../../', import.meta.url) const bundle = 'packages/client/ui-dockkit/lib/index.js' - const css = fileURLToPath(new URL('packages/client/ui-dockkit/lib/components/dockkit.module.css', root)) + const cssBase = fileURLToPath(new URL('packages/client/ui-dockkit/lib/components/dockkit.module', root)) + const extension = mode === 'other-extension' ? '.wasm' : '.css' + // The other stylesheet is the dependency's, which the launcher resolves into + // its source tree: the sweep reports it where an exact-path admission would + // reject the bundle. + const file = mode === 'other-css' + ? fileURLToPath(new URL('packages/client/ui-primitives/src/StateDot.module.css', root)) + : `${cssBase}${extension}` const message = mode === 'error' ? 'dockkit-negative-control' - : `Unknown file extension ".css" for ${mode === 'other-css' ? `${css}.other.css` : css}` + : `Unknown file extension "${extension}" for ${file}` const source = mode === 'clean' ? 'export {}' : `throw Object.assign(new Error(${JSON.stringify(message)}), { code: ${JSON.stringify(mode === 'other-code' ? 'ERR_OTHER' : 'ERR_UNKNOWN_FILE_EXTENSION')} })` @@ -74,7 +84,7 @@ test.each([ expect(finished.signal).toBeNull() expect(finished.status, output).toBe(status) expect(output).toContain(finding) - if (mode === 'error' || mode === 'other-css' || mode === 'other-code') { + if (finding.startsWith('- UNEXPECTED BASELINE FAILURE')) { expect(output).toContain(`- UNEXPECTED BASELINE FAILURE ${bundle}: ${message}\n`) } }) diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index 735563e3e8..a732e36af9 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/extensions/cordis-client-runner/README.md -README.md: 260d4703bda2560a4254962d7b868221d0328ec8 -README.zh.md: 9f101f8ddcf4934a9f9ba4cdc4725050d0e8cbfa +README.md: 153a8bf3b71d2c4c8edf3423dd0b7004083d1ecc +README.zh.md: 207b3d40c56110d734499bb516e41872c53b262a diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index 260d4703bd..153a8bf3b7 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -51,7 +51,7 @@ This section explains the design behind the browser half; the observable behavio ### Design philosophy -The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload is entry removal plus factory invalidation plus style removal. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel. +The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload removes the entry, waits for its fiber's cleanup, then invalidates its factory and removes its styles. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel. ### Source map diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index 9f101f8ddc..207b3d40c5 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 设计理念 -浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载 = 移除 entry + 失效 factory + 撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry-error seam,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。 +浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载先移除 entry,等待其 fiber 清理完成,再使 factory 失效并撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry-error seam,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。 ### 源码地图 diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 91b51cc051..4b96b17335 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-client-runner/src/client/runtime.ts b/packages/extensions/cordis-client-runner/src/client/runtime.ts index c16e420ffb..bed64e7519 100644 --- a/packages/extensions/cordis-client-runner/src/client/runtime.ts +++ b/packages/extensions/cordis-client-runner/src/client/runtime.ts @@ -451,7 +451,9 @@ export class DynamicCordisPackageRunner { this.failures.delete(id) // Entry removal disposes the fiber (slot entries and facade effects // cascade); the factory invalidation makes a later re-load legal. - await this.env.loader.remove(entryId) + const disposal = this.env.loader.resolve(entryId).fiber?.dispose() + this.env.loader.remove(entryId) + await disposal this.env.modules.invalidate(moduleIdOf(id)) styles.dispose() } diff --git a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts index 93cd0d3373..03314c0f96 100644 --- a/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/runner.client.spec.ts @@ -13,7 +13,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Loader } from '@deepseek-ai/cordis-plugin-loader' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId, } from '@deepseek-ai/dsh-api-remotes/client' @@ -52,7 +52,7 @@ interface Bench { invalidated: string[] removed: string[] created: string[] - invoke: ReturnType + invoke: ReturnType Promise>> /** Render failures the runner sent upstream, in order. */ reported: { agentId: SessionId @@ -103,15 +103,15 @@ async function boot(): Promise { return Promise.resolve(entryId) }, resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined }, - remove: async (entryId: string) => { + remove: (entryId: string) => { removed.push(entryId) const entry = fibers.get(entryId) fibers.delete(entryId) - await (entry?.fiber as { dispose(): Promise } | undefined)?.dispose() + void (entry?.fiber as { dispose(): Promise } | undefined)?.dispose() }, } as unknown as Loader - const invoke = vi.fn(() => Promise.resolve(null)) + const invoke = vi.fn<() => Promise>(() => Promise.resolve(null)) const reported: Bench['reported'] = [] // The crash seam is stood in so a test can report an entry failure without a // React render, exactly as the renderer's boundary would; registrations still @@ -298,6 +298,26 @@ describe('failure stages', () => { }) describe('retract', () => { + it('waits for plugin cleanup before invalidating its module factory', async () => { + const bench = await boot() + const started = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + bench.invoke.mockImplementation(() => { + started.resolve(undefined) + return release.promise + }) + await bench.runner.load(half({ code: 'return { apply: (ctx) => ctx.effect(() => () => host.call("cleanup", null)) }' })) + bench.runner.retract(PLUGIN, RUN) + await started.promise + await bench.settle() + expect(bench.removed).toEqual(['entry-1']) + expect(bench.invalidated).toEqual(['dyn/dyn-1']) + release.resolve(undefined) + await bench.settle() + expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1']) + }) + it('unloads at the named revision', async () => { const bench = await boot() await bench.runner.load(half()) diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 8da629b7cf..a4578ed8c6 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index d895cd9b1d..77e0883e7b 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 33c453772b..0e2fa7f89d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -562,9 +562,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ throws: ['an AttachmentError when the durable reference is invalid.'], }, { - signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', + signature: 'readImageRequest( ref: ImageAttachmentRef, target: ImageRequestTarget, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', - parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'policy', description: 'exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], + parameters: [{ name: 'ref', description: 'durable provider-independent normalized attachment reference.' }, { name: 'target', description: 'route-chosen dimensions and byte target; an unmet byte target yields the smallest ladder output.' }, { name: 'signal', description: 'optional cancellation.' }], returns: 'request bytes and the cache/upload identity covering every transform input.', }, ], @@ -3836,7 +3836,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AssembledSection', - declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}', + declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n interpolate?: boolean;\n}', }, { name: 'AssistantMessage', @@ -3984,11 +3984,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CommandDefinition', - declaration: 'export interface CommandDefinition {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + declaration: 'export interface CommandDefinition {\n readonly definitionId?: CommandDefinitionId;\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n readonly recordInput?: boolean;\n readonly handler: (invocation: CommandInvocation) => CommandResult | Promise;\n}', + }, + { + name: 'CommandDefinitionId', + declaration: 'export type CommandDefinitionId = Branded<\'CommandDefinitionId\'>;', }, { name: 'CommandDescriptor', - declaration: 'export interface CommandDescriptor {\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}', + declaration: 'export interface CommandDescriptor {\n readonly definitionId?: CommandDefinitionId;\n readonly name: string;\n readonly description: string;\n readonly input?: CommandInputDescriptor;\n}', }, { name: 'CommandExecution', @@ -4503,8 +4507,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', }, { - name: 'ImageRequestPolicy', - declaration: 'export interface ImageRequestPolicy {\n maxPixels: number;\n maxBytes: number;\n}', + name: 'ImageRequestTarget', + declaration: 'export interface ImageRequestTarget {\n width: number;\n height: number;\n maxBytes: number;\n}', }, { name: 'ImageVariantId', @@ -5052,7 +5056,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly interpolate?: boolean;\n readonly complete?: boolean;\n}', }, { name: 'PromptSectionOrderName', diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index b4aad537a9..4bad5de8e9 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index 7ca180431f..4b8bc3face 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/feedback/command-feedback/README.md -README.md: d4aac50665ef6f1a9d0f18f3c735ef0ce508fbdb -README.zh.md: 457446d9273fff23f9c002c2e7b67e95c2452e44 +README.md: 92d8511c181c2955c3db967ba95284b5b9d76c5e +README.zh.md: 4f698f735d8cc1c178ca50ef8805dc34a4fbd34f diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index d4aac50665..92d8511c18 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -48,7 +48,7 @@ In the Web client a bare `/feedback` — picked from the composer menu or typed | `task-result` | The outcome of the task | | `instruction-following` | Understanding and following instructions | | `product-interaction` | Product features and interaction | -| `service-stability` | Service stability | +| `service-stability` | Stability and speed | | `resource-cost` | Resource usage and cost | | `security-privacy-permission` | Security, privacy, and permissions | | `other` | Anything else | diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index 457446d927..4f698f735d 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -48,7 +48,7 @@ kind: "package-reference" | `task-result` | 任务结果 | | `instruction-following` | 指令理解与遵循 | | `product-interaction` | 产品功能与交互 | -| `service-stability` | 服务稳定性 | +| `service-stability` | 稳定性和速度 | | `resource-cost` | 资源使用与费用 | | `security-privacy-permission` | 安全隐私与权限 | | `other` | 其他 | diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 4b08161f2d..3b0414d813 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback: the record event, the sessionFeedback Host Remote, and the human-facing slash command", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 8756a5b6c8..61fb97cf61 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -9,6 +9,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import type { Session } from '@deepseek-ai/dsh-session' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' @@ -116,8 +117,9 @@ export class SessionFeedbackService extends TypertRemoteService { export function apply(ctx: Context): void { ctx.plugin(SessionFeedbackService) ctx.commands.register({ + definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-feedback'), name: 'feedback', - description: 'record feedback about this session', + description: 'Record feedback about this session', input: { hint: '' }, recordInput: false, handler: executeFeedbackCommand, diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 6ae61e6141..a4e66ceac9 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -95,8 +95,9 @@ describe('@deepseek-ai/dsh-command-feedback registration', () => { expect(loader.unwrapExports(commandFeedback)).toBe(commandFeedback) expect(test.ctx.commands.list(test.agent)).toContainEqual({ + definitionId: '@deepseek-ai/dsh-command-feedback', name: 'feedback', - description: 'record feedback about this session', + description: 'Record feedback about this session', input: { hint: '' }, }) expect(test.ctx.commands.find(test.agent, 'feedback')).toMatchObject({ recordInput: false }) diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 66fab4fb94..90134101c3 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Canonical Session-log ratings and notes for finalized assistant messages", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts index 794c963d27..647e4f03fa 100644 --- a/packages/feedback/message-feedback/src/index.ts +++ b/packages/feedback/message-feedback/src/index.ts @@ -230,10 +230,12 @@ export class MessageFeedbackService extends TypertRemoteService { } const live = this.ctx.sessions.get(sessionId) if (live !== undefined) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return operation(live.snapshotEvents(), async (event) => { if (event !== undefined) { live.append(event.type, event.data) } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const last = live.snapshotEvents().at(-1) if (!(await this.ctx.sessions.flush(live))) { throw new Error( diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts index 2969bd87fb..a844ca9870 100644 --- a/packages/feedback/message-feedback/src/types.ts +++ b/packages/feedback/message-feedback/src/types.ts @@ -24,7 +24,7 @@ export interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string - /** Category the human filed a negative judgment under. */ + /** Category the human filed the judgment under. */ readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 6d6ebd5643..2a70ac51a7 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 0b206c3c16..978f3d0ae7 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 5500995467..e1261a7f14 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 73b9975426..800220fbbb 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index e3e8aadaca..b4bab8a8c4 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index adc2de7cb9..3486c30fd1 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-present/package.json b/packages/fs/tool-present/package.json index 669b79b3e4..18e5bb3d4a 100644 --- a/packages/fs/tool-present/package.json +++ b/packages/fs/tool-present/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-present", "description": "Explicit workspace file delivery declarations for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 56456279bc..92b0be3892 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index eb6dc7bd80..1b0dfe53d3 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index b9e64ee7f2..9238d76632 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' @@ -188,8 +189,9 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman /** Register the Codex-shaped `/goal` command for every composed command adapter. */ export function apply(ctx: Context): void { ctx.commands.register({ + definitionId: CommandDefinitionId('@deepseek-ai/dsh-command-goal'), name: 'goal', - description: 'set or view the goal for a long-running task', + description: 'Set or view the goal for a long-running task', input: { hint: '[|clear|edit |pause|resume]', attachments: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1c1761cf74..98e6e4a73c 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -98,8 +98,9 @@ describe('@deepseek-ai/dsh-command-goal registration', () => { expect(loader.unwrapExports(commandGoal)).toBe(commandGoal) expect(test.ctx.commands.list(test.agent)).toContainEqual({ + definitionId: '@deepseek-ai/dsh-command-goal', name: 'goal', - description: 'set or view the goal for a long-running task', + description: 'Set or view the goal for a long-running task', input: { hint: '[|clear|edit |pause|resume]', attachments: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 1c1b3f461f..6a43469b1d 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/src/invariant.ts b/packages/goal/goal-round-driver/src/invariant.ts index 22ca5c39a1..e716422f27 100644 --- a/packages/goal/goal-round-driver/src/invariant.ts +++ b/packages/goal/goal-round-driver/src/invariant.ts @@ -61,6 +61,7 @@ function validateEvent( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { const prior: SessionEvent[] = [] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { validateEvent(prior, event, fail) prior.push(event) @@ -70,6 +71,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validateEvent(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 04cac37baf..e4664dffef 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 3d8e6a46f0..8c0004f42a 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -43,6 +43,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): GoalFoldState => { const state = emptyGoalFoldState() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) applyChecked(state, event, fail) states.set(session, state) return state diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 5af8513dc2..69f3285384 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 34d3108e5d..eadc0bd8f9 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -30,6 +30,7 @@ function openTurnEvents( ctx: Context, agent: Agent, ): Pick { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = agent.session.snapshotEvents() const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') if (boundary === undefined || boundary.openTurnStartSeq === null) { diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 05046c8ec7..e8fcc60605 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 968f3eb851..b7f22c3002 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 6f7999e48a..a2400f2893 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 660241f8fd..73a817f7de 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -74,6 +74,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): HookTrace => { const trace: HookTrace = { openTurn: null, pending: new Map() } traces.set(session, trace) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index bd189d6cc6..1db3521903 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 9baa76b184..3ed5db1650 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 99cd165d16..fc35b3d067 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index f384828125..46a5dff2f7 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -77,15 +77,20 @@ export async function apply(ctx: Context): Promise { for (const id of [...ids].reverse()) { // Tree teardown (group.stop) can have removed the entry already; // nothing is left to unmount or await then. - if (ctx.loader.store[id] === undefined) continue - // remove() disposes the entry transactionally, so the chooser's unload - // signals completion only after that face quiesced. - await ctx.loader.remove(id) + const entry = ctx.loader.store[id] + if (entry === undefined) continue + const disposal = entry.fiber?.dispose() + ctx.loader.remove(id) + await disposal } } try { for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) { - ids.push(await ctx.loader.create({ name })) + const id = await ctx.loader.create({ name }) + ids.push(id) + const entry = ctx.loader.resolve(id) + if (entry.fiber === undefined) throw new Error(`directory-picker-auto: failed to load ${name}`) + await entry.fiber.await() } } catch (cause) { // Setup owns the entries it created until it returns the disposer: leaving diff --git a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts index 49bf319b0c..83970e6686 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -12,7 +12,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' @@ -205,7 +205,21 @@ describe('real Loader composition', () => { // and the disposer joins the backend's teardown — the service is gone the // moment dispose() settles, with no further loader await. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! - await autoEntry.fiber!.dispose() + const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! + const cleanupStarted = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + onTestFinished(() => { releaseCleanup.resolve(undefined) }) + backendEntry.fiber!.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await releaseCleanup.promise + }) + let disposed = false + const disposal = autoEntry.fiber!.dispose().then(() => { disposed = true }) + await cleanupStarted.promise + await Promise.resolve() + expect(disposed).toBe(false) + releaseCleanup.resolve(undefined) + await disposal expect(entryNames(ctx)).not.toContain(NATIVE) expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE) expect(ctx.get('directoryPicker')).toBeUndefined() @@ -245,7 +259,9 @@ describe('real Loader composition', () => { it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => { stubAttendedHost() - await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/) + const { ctx } = await loadComposition('127.0.0.1', { failSurface: true }) + const chooser = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! + await expect(chooser.fiber!.await()).rejects.toThrow(/directory-picker-auto: failed to load/) // Setup owns both entries until it returns its disposer, so a failed surface // must take the mounted backend with it: otherwise a retry collides with the @@ -259,7 +275,7 @@ describe('real Loader composition', () => { const { ctx, configPath } = await loadComposition('127.0.0.1') const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)! - await ctx.loader.remove(backendEntry.id) + ctx.loader.remove(backendEntry.id) const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! renameControl.remainingFailures = 1 await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow() diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index d9f21b15b2..850081c48a 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index b8aa415ce8..36d4c902da 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 8e7d6951b8..bab9f4c222 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -1,11 +1,12 @@ /** * Driver tests: the child-process message protocol mapped onto the promise, * the WM_CLOSE abort service (including the show-race retry and the kill - * last resort) against fakes, plus the real spawn plumbing — POSIX hosts - * prove the default path rejects cleanly (koffi cannot load ole32 there), - * and win32 hosts briefly open and auto-abort a real dialog. + * last resort) against fakes, plus the real spawn plumbing: hosts that cannot + * open the folder dialog prove the default path rejects cleanly and hosts + * that can briefly open and auto-abort a real one. */ +import { spawnSync } from 'node:child_process' import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts' @@ -41,6 +42,28 @@ function harness(overrides: Partial = {}): Harness { const live = (): AbortSignal => new AbortController().signal +/** CLSID_FileOpenDialog, spelled out here so the probe below owns its own copy. */ +const CLSID_FILE_OPEN_DIALOG = 'dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7' + +/** + * Whether this host can open the shell's folder dialog. Some Windows Server + * images answer `CoCreateInstance(CLSID_FileOpenDialog)` with + * CLASS_E_CLASSNOTAVAILABLE (0x80040111) instead of an object, so no dialog + * can ever appear there. PowerShell activates the class outside the code + * under test, which keeps a GUID or vtable regression in `src` failing the + * smoke below instead of silently skipping it. Every exception from that + * activation reads as refusal, so a host that fails the probe for another + * reason only loses the real-dialog case; a probe that cannot run at all — + * no `powershell.exe` — keeps the win32 assumption. + */ +const opensRealDialog = process.platform === 'win32' && spawnSync('powershell.exe', [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + `try { [void][Activator]::CreateInstance([Type]::GetTypeFromCLSID([Guid]'${CLSID_FILE_OPEN_DIALOG}')) } catch { exit 1 }`, +], { encoding: 'utf8' }).status !== 1 + describe('pickWin32Directory', () => { it('resolves the selected path and the cancellation null', async () => { const first = harness() @@ -145,15 +168,18 @@ describe('pickWin32Directory', () => { expect(close.mock.calls.length).toBeGreaterThan(10) }) - // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped - // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. - it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { + // Hosts without the folder dialog exercise the REAL default plumbing end to + // end: the tsx-bootstrapped worker spawns, loads koffi, and reports the + // refusal — ole32.dll fails to load on POSIX, and a Windows Server image + // that refuses the dialog class reports the same rejection. + it.skipIf(opensRealDialog)('rejects through the real worker where the Win32 surface is unavailable', async () => { await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') }, 30_000) - // win32 hosts run the true COM smoke instead: a real dialog opens briefly - // and the abort service closes it (the same lever a disconnecting client pulls). - it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { + // Hosts with the dialog run the true COM smoke instead: a real dialog opens + // briefly and the abort service closes it (the same lever a disconnecting + // client pulls). + it.skipIf(!opensRealDialog)('opens and abort-closes a real dialog', async () => { const controller = new AbortController() setTimeout(() => { controller.abort() diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 26e93f2440..a930512ac0 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index c8e54a088b..3caae0efac 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/open-in-app/package.json b/packages/host/open-in-app/package.json index 6e9c16bbcb..9c070ef429 100644 --- a/packages/host/open-in-app/package.json +++ b/packages/host/open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-open-in-app", "description": "Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 825d6f1710..168db0b5af 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index 5046c350b8..c5e6df1715 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -93,7 +93,7 @@ describe('PluginInventoryGateway', () => { disabledBy: 'composition', }) - await ctx.loader.remove(pendingId) + ctx.loader.remove(pendingId) expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false) }) diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index e5c6cb19c3..8fbede6744 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index b7ac506b17..9db21bd537 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' +import { Context, FiberState } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer, { renderIndexInjections } from '../src/index.ts' @@ -363,14 +363,10 @@ describe('real Loader composition', () => { let second: Context | undefined try { - let failure: unknown - try { - await loadComposition(takenPort) - } catch (error) { - failure = error - } - second = context - expect(String(failure)).toMatch(/failed to apply loader entry.*EADDRINUSE/) + second = await loadComposition(takenPort) + const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver') + expect(entry?.fiber?.state).toBe(FiberState.FAILED) + await expect(entry?.fiber?.await()).rejects.toThrow('EADDRINUSE') } finally { await second?.fiber.dispose() context = first diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index f97f8f67f8..53d9671751 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index 259f9d4a69..c9fec5ebcb 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/interaction/commands/README.md -README.md: 21ee4617a974e05f1872d295389feff7ba6a2130 -README.zh.md: 17c40e231fd98aec4465e0288f6298807c82306c +README.md: bfcb82786a71a938ed793deb591ff45d32ffe1e7 +README.zh.md: 33e128f12ff765ee00adc7ae907da957c06244d8 diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index 21ee4617a9..bfcb82786a 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -29,7 +29,7 @@ Compose this service when an interactive UI should let users drive agent-side be ### Registering a command -A plugin registers a command with `ctx.commands.register()`: a lowercase name, a description shown in discovery, an optional `input` hint, and a handler that runs against the receiving agent. +A plugin registers a command with `ctx.commands.register()`: a lowercase name, a discovery description, an optional `input` hint, and a handler. An optional branded `definitionId` gives the definition a stable, plugin-namespaced identity for adapters; it is independent of display copy and execution `commandId`. The effective descriptor carries only the selected definition's identity, so a scoped override never inherits the shadowed registration's identity. ```text ctx.commands.register({ @@ -81,7 +81,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi |---|---| | [`src/index.ts`](src/index.ts) | `CommandRuntime` service: registration, scoping, dispatch, lifecycle events | | [`src/types.ts`](src/types.ts) | Command definition, descriptor, execution, and result types | -| [`src/brand.ts`](src/brand.ts) | `CommandId` brand for lifecycle pairing ids | +| [`src/brand.ts`](src/brand.ts) | Stable command-definition identities and per-execution lifecycle ids | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion pairing `command/run` with `command/done` per session log | ### Lifecycle events diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index 17c40e231f..33e128f12f 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 注册命令 -插件用 `ctx.commands.register()` 注册命令:小写名称、在发现界面中展示的描述、可选的 `input` 提示,以及针对接收 agent 运行的处理器。 +插件通过 `ctx.commands.register()` 注册命令,提供小写名称、发现界面中的说明、可选的 `input` 提示和处理器。可选的品牌类型字段 `definitionId` 为适配器提供带插件命名空间的稳定定义标识,它独立于显示文案和每次执行的 `commandId`。有效描述符只携带被选中定义的标识,作用域覆盖不会继承被遮蔽注册项的标识。 ```text ctx.commands.register({ @@ -81,7 +81,7 @@ ctx.commands.register({ |---|---| | [`src/index.ts`](src/index.ts) | `CommandRuntime` 服务:注册、作用域、分派、生命周期事件 | | [`src/types.ts`](src/types.ts) | 命令定义、描述符、执行与结果类型 | -| [`src/brand.ts`](src/brand.ts) | 生命周期配对 id 的 `CommandId` brand | +| [`src/brand.ts`](src/brand.ts) | 稳定命令定义标识和每次执行的生命周期 id | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:按会话日志配对 `command/run` 与 `command/done` | ### 生命周期事件 diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 5cee082b4f..0be908a553 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/src/brand.ts b/packages/interaction/commands/src/brand.ts index d9232d5c11..a841ce2502 100644 --- a/packages/interaction/commands/src/brand.ts +++ b/packages/interaction/commands/src/brand.ts @@ -1,6 +1,5 @@ /** - * dsh-commands' owned branded id: command lifecycle pairing across the - * session log, the wire admission response, and client-side flow pairing. + * Command definition identities and execution ids for discovery and lifecycle pairing. * * The `Branded` primitive lives in `@deepseek-ai/dsh-brand`; this module * is a pure type/constructor outlet (no cordis imports, no module @@ -12,6 +11,18 @@ import type { Branded } from '@deepseek-ai/dsh-brand' +/** Stable, plugin-owned identity of a command definition, independent of its name and copy. */ +export type CommandDefinitionId = Branded<'CommandDefinitionId'> + +/** + * Brand a plugin-namespaced command definition identity. + * @param id - stable identity chosen by the registering plugin. + * @returns the same string, branded; no validation is performed. + */ +export function CommandDefinitionId(id: string): CommandDefinitionId { + return id as CommandDefinitionId +} + /** * Pairs one command execution's `command/run`/`command/done` lifecycle * records with each other and with the `command.execute` admission response. diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 0e94998066..5464704516 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -15,6 +15,7 @@ import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' import { CommandId } from './brand.ts' +import type { CommandDefinitionId } from './brand.ts' import type { CommandDescriptor, CommandExecution, @@ -23,7 +24,7 @@ import type { CommandSubmitAttachment, } from './types.ts' -export { CommandId } from './brand.ts' +export { CommandDefinitionId, CommandId } from './brand.ts' export type * from './types.ts' export const name = 'commands' @@ -58,6 +59,8 @@ export interface CommandInvocation { /** Plugin-owned command registration. */ export interface CommandDefinition { + /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */ + readonly definitionId?: CommandDefinitionId /** Lowercase command name without the leading slash. */ readonly name: string /** Human-readable summary used in discovery UI. */ @@ -205,6 +208,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { }) } const normalized = Object.freeze({ + ...definition.definitionId === undefined ? {} : { definitionId: definition.definitionId }, name: definition.name, description: definition.description, ...input === undefined ? {} : { input }, @@ -212,6 +216,7 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { handler: definition.handler, }) const descriptor = Object.freeze({ + ...normalized.definitionId === undefined ? {} : { definitionId: normalized.definitionId }, name: normalized.name, description: normalized.description, ...normalized.input === undefined ? {} : { input: normalized.input }, diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index 7bce241224..5eb8194b1a 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -35,6 +35,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`) } const source = event.data.sourceEventSeq + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const sourceEvent = source === undefined ? undefined : session.eventAt(source) if (source !== undefined && (event.data.kind !== 'success' @@ -46,6 +47,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant } } for (const session of ctx.sessions.list()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(session, event) } ctx.on('internal/dispatch', (_mode, eventName, args) => { diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 7277766871..c1ee991f81 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -8,7 +8,7 @@ */ import type { SessionSeq } from '@deepseek-ai/dsh-session/types' -import type { CommandId } from './brand.ts' +import type { CommandDefinitionId, CommandId } from './brand.ts' import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' /** One browser-submitted command attachment: encoded image input or a staged file receipt. */ @@ -55,6 +55,8 @@ export interface CommandExecution { /** Handler-free immutable command view returned to UI adapters. */ export interface CommandDescriptor { + /** Stable plugin-owned identity; absent for definitions without identity-based client behavior. */ + readonly definitionId?: CommandDefinitionId /** Lowercase command name without the leading slash. */ readonly name: string /** Human-readable summary used in discovery UI. */ diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 92a12d921d..f733e50b0e 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -4,7 +4,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import CommandRuntime, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands' +import CommandRuntime, { CommandDefinitionId, parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' function command(name: string, text = `ran:${name}`): CommandDefinition { @@ -58,6 +58,7 @@ describe('CommandRuntime', () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const definition: CommandDefinition = { + definitionId: CommandDefinitionId('example/inspect'), name: 'inspect', description: 'Inspect state', input: { hint: '' }, @@ -67,6 +68,7 @@ describe('CommandRuntime', () => { const listed = ctx.commands.list(agent) expect(listed).toEqual([{ + definitionId: CommandDefinitionId('example/inspect'), name: 'inspect', description: 'Inspect state', input: { hint: '' }, @@ -74,7 +76,7 @@ describe('CommandRuntime', () => { expect(Object.isFrozen(listed)).toBe(true) expect(Object.isFrozen(listed[0])).toBe(true) expect(Object.isFrozen(listed[0]?.input)).toBe(true) - expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' }) + expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect', definitionId: CommandDefinitionId('example/inspect') }) expect(ctx.commands.find(agent, 'missing')).toBeUndefined() }) @@ -91,16 +93,18 @@ describe('CommandRuntime', () => { const ctx = await mount() const { scope, agent } = await mintAgentScope(ctx, 'a') const other = { id: 'other' as SessionId } as Agent - ctx.commands.register(command('shared', 'global')) + ctx.commands.register({ ...command('shared', 'global'), definitionId: CommandDefinitionId('example/shared') }) scope.ctx.commands.register(command('shared', 'scoped')) expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared']) expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined() + expect(ctx.commands.list(agent)[0]).not.toHaveProperty('definitionId') expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared']) expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result) .toEqual({ kind: 'success', text: 'scoped' }) await scope.dispose() + expect(ctx.commands.list(agent)[0]?.definitionId).toBe('example/shared') expect((await ctx.commands.execute(agent, '/shared', [], new AbortController().signal))?.result.text).toBe('global') }) diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index dd85caf5f5..ebba3f0f18 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index df7b9195dd..9e9a6ec91e 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -11,6 +11,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' @@ -254,6 +255,7 @@ export class PermissionPresetService extends Service { // activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ + definitionId: CommandDefinitionId('@deepseek-ai/dsh-permission-presets'), name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' }, diff --git a/packages/interaction/permission-presets/src/invariant.ts b/packages/interaction/permission-presets/src/invariant.ts index dee9db2451..700b42ba6c 100644 --- a/packages/interaction/permission-presets/src/invariant.ts +++ b/packages/interaction/permission-presets/src/invariant.ts @@ -21,6 +21,7 @@ function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure /** Install validation that loaded and newly appended preset events remain resolvable. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(ctx, event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index e825300c74..03efa1b837 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 71faeb1b71..6e8354834c 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index 8e1a8d05d4..8a5f506bf1 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -76,6 +76,7 @@ const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may */ function hasOpenTurn(session: Session): boolean { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const type = session.eventAt(SessionSeq(seq))?.type if (type === 'turn/start') return true if (type === 'turn/end') return false @@ -243,6 +244,7 @@ export class ApprovalService extends Service { */ overrideOf(session: Session): ApprovalPolicy | undefined { for (let seq = session.seq - 1; seq >= 0; seq -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) if (event?.type === 'approval/policy') return event.data.policy } diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index 8ff7a9953e..d356ca3698 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -64,6 +64,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): ApprovalTrace => { const trace: ApprovalTrace = { openTurn: null, pending: new Set() } traces.set(session, trace) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'turn/start') trace.openTurn = event.data.turn else if (event.type === 'turn/end') trace.openTurn = null diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 0e428bbdb4..cbe8a2854b 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 31f9267c94..110ba43f3e 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index c5d4a858cf..ff310752ce 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 6857a8ef28..8c6adfb1c3 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 73810ece1a..88bb421385 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index a33c4084ce..95851bc4f2 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 41646097f80dfad9cc523595b9d2820c28fde66b -README.zh.md: c7a1e40952dff7a804880dcd266114cecb20918f +README.md: b3ccd5bc7ed8c66ac4c0a5212d834fa8551d553d +README.zh.md: edfd5e89292f1e9ad478ccedb5601a7e95daea45 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 41646097f8..b3ccd5bc7e 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -74,7 +74,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Streaming with thinking and images -An image-capable route resolves each durable reference into a deterministic request version under its pixel and byte budgets. `imagePixelBudget` accepts a positive integer or `low`; omission uses 640,000 total pixels, `low` uses 512×512 total pixels, and `imageMaxBytes` defaults to 1 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. +An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. The adapter normally uploads those exact request bytes through the DeepSeek Files API and sends file-id blocks. A failed or timed-out file resolution rebuilds the whole chat request with the same request versions as base64 data URLs; one request never mixes file ids and inline images. Cached ids are scoped by endpoint and API key, refreshed before expiry, invalidated from provider stale-file errors, and resolved through singleflight with waiter-local cancellation. Quota failure deletes one configured batch of the oldest harness-owned files before one upload retry. @@ -190,8 +190,6 @@ These limits define where the adapter stops and future work begins. They are cur - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. - The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts). -- The default request-image projection caps total pixels at 640,000, below the provider's roughly 1300×1300 processing budget, so it can discard usable detail. Each model's `imagePixelBudget` can override this default; changing the default affects request content and needs separate snapshot verification ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.md)). - ### Dev Note diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index c7a1e40952..edfd5e8929 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -74,7 +74,7 @@ kind: "package-reference" ### 带 thinking 与图片的流式调用 -支持图片的路由会在自身像素与字节预算内把每个持久引用解析为确定性请求版本。`imagePixelBudget` 接受正整数或 `low`;省略时使用总计 640,000 像素,`low` 使用总计 512×512 像素,`imageMaxBytes` 默认为 1 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 +支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 适配器通常通过 DeepSeek Files API 上传这些确切请求字节,并发送 file-id 块。文件解析失败或超时会用相同请求版本的 base64 data URL 重建整份 chat 请求;一次请求绝不混用 file id 与内联图片。缓存 id 按端点与 API key 限定作用域,在到期前刷新,根据提供方的陈旧文件错误失效,并通过带等待方局部取消的 singleflight 解析。配额失败会先删除一批配置数量的最旧 harness 文件,再重试一次上传。 @@ -190,8 +190,6 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 - 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。 -- 默认请求图片投影限制为 640,000 总像素,低于提供方约 1300×1300 的处理预算,可能丢弃可用细节。每个模型的 `imagePixelBudget` 可以覆盖默认值。更改默认值会改变请求内容,需要单独验证快照([决策](../../../.agents/notes/implemented/bug-fix/2026-09-10-deepseek-image-token-calculator-v41.zh.md))。 - ### 开发备注 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 13fd0827f4..9db0a60fdc 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index be165c1d4c..5622209cea 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -38,7 +38,7 @@ import type { } from '@deepseek-ai/dsh-deepseek-llm-api-extensions' import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { ImageWireLocation, RequestDefaults } from './serialize.ts' -import { deepSeekImageRequestPricing, resolveRequestImagePolicy } from './request-pricing.ts' +import { deepSeekImageRequestPricing, resolveRequestImageMaxBytes, resolveRequestImageTarget } from './request-pricing.ts' import { DeepSeekFileStore } from './file-store.ts' import type { DeepSeekFilePolicy } from './file-store.ts' import type { DeepSeekFileId } from './file-id.ts' @@ -60,7 +60,11 @@ export interface DeepSeekCatalogModel { maxTokens?: number /** Accepted request modalities; omission is text-only. */ inputModalities?: ModelModality[] - /** Total-pixel budget for one deterministic request preview, or the 512-by-512 `low` preset. */ + /** + * Total-pixel budget replacing the published token-grid projection for one + * deterministic request preview, or the 512-by-512 `low` preset; omission + * projects onto the token grid. + */ imagePixelBudget?: number | 'low' /** Encoded-byte target for one deterministic request preview; the smallest quality-ladder output is used when no quality fits. */ imageMaxBytes?: number @@ -225,10 +229,9 @@ async function prepareRequestImages( ): Promise> { const refs = new Map() for (const message of options.messages) collectImageRefs(message.content, refs) - const policy = resolveRequestImagePolicy(model) const orderedRefs = [...refs.values()] const projected = await Promise.all(orderedRefs.map( - ref => attachments.readImageRequest(ref, policy, signal), + ref => attachments.readImageRequest(ref, resolveRequestImageTarget(model, ref), signal), )) return new Map(orderedRefs.map((ref, index) => ( [ref.attachmentId, projected[index] as RequestImageAttachment] @@ -552,18 +555,18 @@ export class DeepSeekAdapter extends LlmAdapter { const fileConnection = { baseURL: connection.baseURL, apiKey } const model = connection.models.find(entry => entry.id === options.model) - const policy = model === undefined ? undefined : resolveRequestImagePolicy(model) + const maxBytes = model === undefined ? undefined : resolveRequestImageMaxBytes(model) const resolveImageAccess = attachments === undefined ? undefined : (ref: ImageAttachmentRef): ImageAttachmentAccess | undefined => this.config.resolveImageAccess?.(attachments, ref) const imageAccessOptions = resolveImageAccess === undefined ? {} : { resolveImageAccess } - const requestMessages = policy === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { + const requestMessages = maxBytes === undefined ? options.messages : offloadRequestImagesWithPolicy(options.messages, { representation: 'raw', maxBytes: connection.maxRequestFilesBytes, maxImages: connection.maxImagesPerRequest, byteQuantum: connection.imageOffloadByteQuantum, countQuantum: connection.imageOffloadCountQuantum, - byteLength: ref => Math.min(ref.bytes, policy.maxBytes), + byteLength: ref => Math.min(ref.bytes, maxBytes), placeholder: ref => offloadedImageText(ref, resolveImageAccess?.(ref)), }) const requestOptions = requestMessages === options.messages ? options : { ...options, messages: [...requestMessages] } diff --git a/packages/llm/llm-deepseek/src/image-tokens.ts b/packages/llm/llm-deepseek/src/image-tokens.ts index 609eb7269c..dfef215ee6 100644 --- a/packages/llm/llm-deepseek/src/image-tokens.ts +++ b/packages/llm/llm-deepseek/src/image-tokens.ts @@ -11,6 +11,8 @@ * @module dsh-llm-deepseek/image-tokens */ +import { longEdgeDimensions } from '@deepseek-ai/dsh-attachment' +import type { ProjectedDimensions } from '@deepseek-ai/dsh-attachment' /** Vision patch edge in pixels. */ const PATCH_SIZE = 14 /** Per-axis patch-to-token downsampling ratio. */ @@ -116,6 +118,25 @@ function sameResize(a: GridResize, b: GridResize): boolean { && a.numTokens === b.numTokens } +/** + * Dimensions the harness sends so the provider keeps the whole image: the + * source itself when its patch-padded grid fits the token cap, otherwise the + * source aspect ratio at the solved grid's long edge. The provider pads the + * short edge to whole patches on its side. Rounding the aspect-preserving + * short edge can change the token count from the source's solved grid; + * request pricing uses the sent dimensions. Small images are never enlarged. + * @param width - positive integer source width in pixels. + * @param height - positive integer source height in pixels. + * @returns the request dimensions to encode. + */ +export function deepSeekRequestImageDimensions(width: number, height: number): ProjectedDimensions { + const paddedWidth = ceilDiv(width, PATCH_SIZE) * PATCH_SIZE + const paddedHeight = ceilDiv(height, PATCH_SIZE) * PATCH_SIZE + if (gridTokens(gridCells(paddedHeight), gridCells(paddedWidth)) <= MAX_IMAGE_TOKENS) return { width, height } + const solved = solveResizeRatio(height, width, MAX_IMAGE_TOKENS) + return longEdgeDimensions(width, height, width >= height ? solved.bestWidth : solved.bestHeight) +} + /** * Vision tokens DeepSeek charges for one request image of the given * dimensions. diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index d536fc1f9b..e3f470d6fa 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -38,11 +38,9 @@ import { } from './adapter.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' import { - DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET, DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES, - DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, } from './request-pricing.ts' export { @@ -65,11 +63,12 @@ export { DEFAULT_MAX_IMAGES_PER_REQUEST, DEFAULT_MAX_REQUEST_FILES_BYTES, DEFAULT_REQUEST_IMAGE_MAX_BYTES, - DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + REQUEST_IMAGE_MAX_DIMENSION, deepSeekImageRequestPricing, - resolveRequestImagePolicy, + resolveRequestImageMaxBytes, + resolveRequestImageTarget, } from './request-pricing.ts' -export { deepSeekImageTokens } from './image-tokens.ts' +export { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts' export { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from './file-store.ts' export type { DeepSeekFileConnection, DeepSeekFilePolicy, DeepSeekFileReference } from './file-store.ts' export { DeepSeekFilesClient, MAX_FILE_EXPIRY_SECONDS, MAX_FILE_UPLOAD_BYTES, MAX_STORED_FILE_BYTES, MAX_STORED_FILE_COUNT, MIN_FILE_EXPIRY_SECONDS } from './files-api.ts' @@ -95,8 +94,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ name: 'DeepSeek-V41-Flash', contextWindow: DEFAULT_CONTEXT_WINDOW, inputModalities: ['text', 'image'], - imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, - imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, systemPromptUpdate: 'in-history', }, { @@ -116,8 +113,6 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ name: 'DeepSeek-V4-Flash-Vision-Exp', contextWindow: DEFAULT_CONTEXT_WINDOW, inputModalities: ['text', 'image'], - imagePixelBudget: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, - imageMaxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES, }, ] @@ -285,9 +280,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee inputModalities: [...inputModalities], ...hasImage ? { - imagePixelBudget: model.imagePixelBudget === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET, + ...model.imagePixelBudget === undefined ? {} : { imagePixelBudget: model.imagePixelBudget }, imageMaxBytes: model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES, } : {}, diff --git a/packages/llm/llm-deepseek/src/request-pricing.ts b/packages/llm/llm-deepseek/src/request-pricing.ts index acdf4dc15a..7d6091fcc2 100644 --- a/packages/llm/llm-deepseek/src/request-pricing.ts +++ b/packages/llm/llm-deepseek/src/request-pricing.ts @@ -1,6 +1,6 @@ /** * Provider-side request-image pricing for DeepSeek routes: reproduces the - * adapter's deterministic request projection (per-model pixel budget, + * adapter's deterministic request projection (per-model request target, * oldest-first offload under the raw-byte and count budgets) and prices every * retained image with the published vision-token accounting. Consumed * synchronously by the token meter through `LlmAdapter.imageRequestPricing`; @@ -11,38 +11,57 @@ import { offloadedImageText, offloadedImagePrefixCount, requestImageHandleText, textOnlyImageText } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentAccessResolver, LlmImageRequestPrice, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' -import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, ImageRequestPolicy } from '@deepseek-ai/dsh-attachment' -import { deepSeekImageTokens } from './image-tokens.ts' +import { longEdgeDimensions, requestImageDimensions } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageRequestTarget } from '@deepseek-ai/dsh-attachment' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from './image-tokens.ts' import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts' /** Default bound on accumulated file-referenced image bytes per request. */ export const DEFAULT_MAX_REQUEST_FILES_BYTES = 128 * 1024 * 1024 /** Provider request image-count limit. */ export const DEFAULT_MAX_IMAGES_PER_REQUEST = 600 -/** Default total-pixel budget for harness request-image projection. */ -export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 640_000 /** Total-pixel budget matching provider low-detail image input. */ export const DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET = 512 * 512 /** Encoded-byte target for one deterministic model-request image; the smallest quality-ladder output is used when no quality fits. */ -export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 +export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 2 * 1024 * 1024 +/** + * Provider per-side limit for a request carrying 15 or more images, applied + * to every request image so the image count never changes a projection. + */ +export const REQUEST_IMAGE_MAX_DIMENSION = 4096 /** - * Resolve the request-image budgets owned by one DeepSeek model route. + * Resolve the encoded-byte target one DeepSeek model route applies to every request image. * @param model - Advertised model route and its optional image overrides. - * @returns Complete pixel and encoded-byte budgets. + * @returns the route's encoded-byte target. * @internal */ -export function resolveRequestImagePolicy(model: DeepSeekCatalogModel): ImageRequestPolicy { - const maxPixels = model.imagePixelBudget === 'low' - ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET - : model.imagePixelBudget ?? DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET - return { - maxPixels, - maxBytes: model.imageMaxBytes === undefined - ? DEFAULT_REQUEST_IMAGE_MAX_BYTES - : model.imageMaxBytes, - } +export function resolveRequestImageMaxBytes(model: DeepSeekCatalogModel): number { + return model.imageMaxBytes ?? DEFAULT_REQUEST_IMAGE_MAX_BYTES +} + +/** + * Resolve the deterministic request target one DeepSeek model route chooses + * for one source image: the published token grid unless the model overrides + * it with a pixel budget, then the provider per-side limit, then the route's + * encoded-byte target. Small images are never enlarged. + * @param model - Advertised model route and its optional image overrides. + * @param source - intrinsic dimensions of the normalized attachment. + * @returns Complete request dimensions and encoded-byte target. + * @internal + */ +export function resolveRequestImageTarget( + model: DeepSeekCatalogModel, + source: Pick, +): ImageRequestTarget { + const budget = model.imagePixelBudget === 'low' ? DEFAULT_LOW_DETAIL_IMAGE_PIXEL_BUDGET : model.imagePixelBudget + const projected = budget === undefined + ? deepSeekRequestImageDimensions(source.width, source.height) + : requestImageDimensions(source.width, source.height, budget) + const capped = Math.max(projected.width, projected.height) > REQUEST_IMAGE_MAX_DIMENSION + ? longEdgeDimensions(source.width, source.height, REQUEST_IMAGE_MAX_DIMENSION) + : projected + return { ...capped, maxBytes: resolveRequestImageMaxBytes(model) } } /** @@ -79,11 +98,11 @@ export function deepSeekImageRequestPricing( if (catalogModel?.inputModalities?.includes('image') !== true) { return { priceImages: images => images.map(textOnlyPrice) } } - const policy = resolveRequestImagePolicy(catalogModel) + const maxBytes = resolveRequestImageMaxBytes(catalogModel) return { priceImages: (images) => { const offloaded = offloadedImagePrefixCount( - images.map(ref => Math.min(ref.bytes, policy.maxBytes)), + images.map(ref => Math.min(ref.bytes, maxBytes)), { maxBytes: connection.maxRequestFilesBytes, maxImages: connection.maxImagesPerRequest, @@ -95,10 +114,10 @@ export function deepSeekImageRequestPricing( if (index < offloaded) { return { visualTokens: 0, text: offloadedImageText(ref, resolveAccess?.(ref)) } } - const dimensions = requestImageDimensions(ref.width, ref.height, policy.maxPixels) + const target = resolveRequestImageTarget(catalogModel, ref) return { - visualTokens: deepSeekImageTokens(dimensions.width, dimensions.height), - text: requestImageHandleText(ref, dimensions, resolveAccess?.(ref)), + visualTokens: deepSeekImageTokens(target.width, target.height), + text: requestImageHandleText(ref, target, resolveAccess?.(ref)), } }) }, diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index fc14b03e55..12251be841 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -14,7 +14,7 @@ import LocalAttachments from '@deepseek-ai/dsh-attachment-local' import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -92,7 +92,7 @@ class E2eAttachmentStore extends AttachmentStore { override readImageRequest( _ref: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, _signal?: AbortSignal, ): Promise { return Promise.resolve(this.version) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index fefe4815b4..3f8c261b3a 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -22,7 +22,7 @@ import type { PreparedDeepSeekLlmApiExtensions } from '@deepseek-ai/dsh-deepseek import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { httpErrorCode } from '../src/adapter.ts' -import { resolveRequestImagePolicy } from '../src/request-pricing.ts' +import { resolveRequestImageTarget } from '../src/request-pricing.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' import type { Behavior } from './mock-server.ts' @@ -141,22 +141,29 @@ function successfulSseResponse(): Response { }) } -describe('request image policy', () => { +describe('request image target', () => { it.each([ [ { id: 'default' }, - { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + { width: 1302, height: 1302, maxBytes: 2 * 1024 * 1024 }, ], [ { id: 'low', imagePixelBudget: 'low' as const }, - { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }, + { width: 512, height: 512, maxBytes: 2 * 1024 * 1024 }, ], [ { id: 'custom', imagePixelBudget: 320_000, imageMaxBytes: 512_000 }, - { maxPixels: 320_000, maxBytes: 512_000 }, + { width: 565, height: 565, maxBytes: 512_000 }, ], - ])('resolves route-owned defaults and overrides for %s', (model, expected) => { - expect(resolveRequestImagePolicy(model)).toEqual(expected) + ])('resolves route-owned defaults and overrides for %s on a 4096x4096 source', (model, expected) => { + expect(resolveRequestImageTarget(model, { width: 4096, height: 4096 })).toEqual(expected) + }) + + it('caps every request image at the provider per-side limit', () => { + expect(resolveRequestImageTarget({ id: 'default' }, { width: 8192, height: 78 })) + .toEqual({ width: 4096, height: 39, maxBytes: 2 * 1024 * 1024 }) + expect(resolveRequestImageTarget({ id: 'custom', imagePixelBudget: 640_000 }, { width: 10_000, height: 100 })) + .toEqual({ width: 4096, height: 41, maxBytes: 2 * 1024 * 1024 }) }) it('answers image request pricing from the current connection snapshot', () => { @@ -407,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => { bytes: 3, }]) expect(signalSeen[0]).toBeInstanceOf(AbortSignal) - expect(policies).toEqual([{ maxPixels: 640_000, maxBytes: 1024 * 1024 }]) + expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }]) }) it('falls back to one all-base64 request when Files API resolution fails', async () => { @@ -621,7 +628,7 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenCalledWith( recent, - { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, expect.any(AbortSignal), ) const body = server.requests[0] as { messages: unknown[] } @@ -672,13 +679,13 @@ describe('DeepSeekAdapter against a mock server', () => { expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 1, imageRef, - { maxPixels: 512 * 512, maxBytes: 512_000 }, + { width: 1, height: 1, maxBytes: 512_000 }, expect.any(AbortSignal), ) expect(attachmentMocks.readImageRequest).toHaveBeenNthCalledWith( 2, imageRef, - { maxPixels: 320_000, maxBytes: 1024 * 1024 }, + { width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }, expect.any(AbortSignal), ) }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index ffac6880e5..6f26fd083e 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -8,7 +8,7 @@ import AttachmentStore, { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh- import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -70,7 +70,7 @@ class StaticAttachmentStore extends AttachmentStore { override readImageRequest( ref: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, _signal?: AbortSignal, ): Promise { return Promise.resolve({ diff --git a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts index 0e6b5f5b9b..0796e3d113 100644 --- a/packages/llm/llm-deepseek/tests/image-tokens.spec.ts +++ b/packages/llm/llm-deepseek/tests/image-tokens.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { deepSeekImageTokens } from '../src/image-tokens.ts' +import { deepSeekImageTokens, deepSeekRequestImageDimensions } from '../src/image-tokens.ts' describe('DeepSeek image tokens', () => { // Reference values from the provider's published image token calculator @@ -47,3 +47,33 @@ describe('DeepSeek image tokens', () => { expect(deepSeekImageTokens(89, 2076)).toBe(254) }) }) + +describe('DeepSeek request image dimensions', () => { + it('can cross a token-cell boundary when preserving the source aspect ratio', () => { + const sent = deepSeekRequestImageDimensions(1224, 1429) + expect(sent).toEqual({ width: 1187, height: 1386 }) + expect(deepSeekImageTokens(1224, 1429)).toBe(959) + expect(deepSeekImageTokens(sent.width, sent.height)).toBe(992) + }) + + it.each([ + [800, 800, 800, 800], + [1302, 1302, 1302, 1302], + [8192, 78, 8192, 78], + [1, 9000, 1, 9000], + ])('sends %sx%s unchanged because its padded grid fits the cap', (width, height, expectedWidth, expectedHeight) => { + expect(deepSeekRequestImageDimensions(width, height)).toEqual({ width: expectedWidth, height: expectedHeight }) + }) + + it.each([ + [1303, 1303, 1302, 1302], + [2048, 2048, 1302, 1302], + [2048, 1024, 1848, 924], + [3840, 2160, 1708, 961], + [1080, 2400, 838, 1862], + ])('downscales %sx%s to %sx%s at the solved long edge', (width, height, expectedWidth, expectedHeight) => { + const sent = deepSeekRequestImageDimensions(width, height) + expect(sent).toEqual({ width: expectedWidth, height: expectedHeight }) + expect(deepSeekImageTokens(sent.width, sent.height)).toBe(deepSeekImageTokens(width, height)) + }) +}) diff --git a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts index ccaec2e51a..40ae5d874b 100644 --- a/packages/llm/llm-deepseek/tests/request-pricing.spec.ts +++ b/packages/llm/llm-deepseek/tests/request-pricing.spec.ts @@ -44,17 +44,41 @@ describe('DeepSeek request-image pricing', () => { const image = ref('photo', 1920, 1080) const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image]) expect(prices).toEqual([{ - visualTokens: 407, - text: requestImageHandleText(image, { width: 1066, height: 600 }), + visualTokens: 968, + text: requestImageHandleText(image, { width: 1708, height: 961 }), }]) }) - it.each([[8192, 1], [1, 8192]])('prices a %sx%s image at the token cap within the default pixel budget', (width, height) => { + it.each([ + [8192, 1, 4096, 1, 832], + [1, 8192, 1, 4096, 1024], + ])('prices a %sx%s image at its per-side-capped %sx%s request dimensions', (width, height, cappedWidth, cappedHeight, tokens) => { const image = ref('thin', width, height) const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image]) expect(prices).toEqual([{ - visualTokens: 1024, - text: requestImageHandleText(image, { width, height }), + visualTokens: tokens, + text: requestImageHandleText(image, { width: cappedWidth, height: cappedHeight }), + }]) + }) + + it('prices the sent dimensions when aspect-preserving projection changes the token grid', () => { + const image = ref('portrait', 1224, 1429) + const prices = deepSeekImageRequestPricing(connection(), 'vision').priceImages([image]) + expect(prices).toEqual([{ + visualTokens: 992, + text: requestImageHandleText(image, { width: 1187, height: 1386 }), + }]) + }) + + it('honors a numeric pixel budget override', () => { + const image = ref('photo', 4096, 4096) + const options = resolveAdapterOptions({ + models: [{ ...VISION_MODEL, imagePixelBudget: 640_000 }], + }) + const prices = deepSeekImageRequestPricing(options, 'vision').priceImages([image]) + expect(prices).toEqual([{ + visualTokens: 422, + text: requestImageHandleText(image, { width: 800, height: 800 }), }]) }) @@ -97,7 +121,7 @@ describe('DeepSeek request-image pricing', () => { }) it('caps each occurrence at the per-image byte target before the byte budget', () => { - // Each 5 MiB source counts as the 1 MiB request target, so a 2 MiB budget + // Each 5 MiB source counts as the 2 MiB request target, so a 4 MiB budget // with a one-byte quantum removes exactly the oldest occurrence. const oversized = 5 * 1024 * 1024 const images = [ @@ -106,7 +130,7 @@ describe('DeepSeek request-image pricing', () => { ref('third', 800, 800, oversized), ] const prices = deepSeekImageRequestPricing( - connection({ maxRequestFilesBytes: 2 * 1024 * 1024, imageOffloadByteQuantum: 1 }), + connection({ maxRequestFilesBytes: 4 * 1024 * 1024, imageOffloadByteQuantum: 1 }), 'vision', ).priceImages(images) expect(prices.map(price => price.visualTokens)).toEqual([0, 422, 422]) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c1a9fea59e..6a10d5a6bb 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 2a4369a5ba..8126bfc04d 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -11,11 +11,12 @@ import type { AttachmentId, AttachmentStore, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' +import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts' /** Join the text blocks of a harness message. */ @@ -102,14 +103,14 @@ function collectImageRefs( async function prepareRequestImages( messages: readonly Message[], attachments: AttachmentStore, - policy: ImageRequestPolicy, + budget: PiImageRequestBudget, signal?: AbortSignal, ): Promise> { const refs = new Map() for (const message of messages) collectImageRefs(message.content, refs) const orderedRefs = [...refs.values()] const prepared = await Promise.all(orderedRefs.map( - ref => attachments.readImageRequest(ref, policy, signal), + ref => attachments.readImageRequest(ref, requestImageTarget(ref, budget), signal), )) const versions = new Map() for (const [index, ref] of orderedRefs.entries()) { @@ -222,7 +223,20 @@ export interface PiImageRequestContext { /** Request-level bound on base64-encoded image payload; omission leaves every image in place. */ maxRequestImageBytes?: number /** Route pixel and raw encoded-byte budgets. */ - requestImagePolicy?: ImageRequestPolicy + requestImagePolicy?: PiImageRequestBudget +} + +/** Per-route budgets from which each request image's target is derived. */ +export interface PiImageRequestBudget { + /** Total-pixel budget; larger sources are downscaled proportionally. */ + maxPixels: number + /** Encoded-byte target for one request image. */ + maxBytes: number +} + +/** Deterministic request target for one source under the route budgets. */ +function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageRequestBudget): ImageRequestTarget { + return { ...requestImageDimensions(ref.width, ref.height, budget.maxPixels), maxBytes: budget.maxBytes } } /** diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a54ec17697..a247b20e42 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -4,7 +4,7 @@ import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh- import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -254,7 +254,7 @@ describe('PiAiAdapter provider routing', () => { Promise.resolve({ ref, data: Uint8Array.of(1) })) const readImageRequest = vi.fn(( value: ImageAttachmentRef, - _policy: ImageRequestPolicy, + _target: ImageRequestTarget, _signal?: AbortSignal, ): Promise => ( Promise.resolve({ @@ -299,7 +299,7 @@ describe('PiAiAdapter provider routing', () => { override readImageRequest( value: ImageAttachmentRef, - policy: ImageRequestPolicy, + policy: ImageRequestTarget, signal?: AbortSignal, ): Promise { return readImageRequest(value, policy, signal) @@ -325,7 +325,8 @@ describe('PiAiAdapter provider routing', () => { expect(result.finish.kind).toBe('error') expect(readImageRequest).toHaveBeenCalledWith(ref, { - maxPixels: 2048 * 2048, + width: 1, + height: 1, maxBytes: 1024 * 1024, }, expect.any(AbortSignal)) expect(JSON.stringify(server.requests[0])).toContain(MODEL_IMAGE_PATH) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 576b84a235..59f952ad86 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -3,7 +3,7 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, } from '@deepseek-ai/dsh-attachment' import { ToolCallId, createMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm' @@ -38,7 +38,7 @@ function requestImage(value: ImageAttachmentRef, data: Uint8Array): RequestImage function projectionStore( readImageRequest: ( value: ImageAttachmentRef, - policy: ImageRequestPolicy, + target: ImageRequestTarget, signal?: AbortSignal, ) => Promise = vi.fn((value: ImageAttachmentRef) => ( Promise.resolve(requestImage(value, Uint8Array.of(1))) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 74ecb3d71d..4347d74858 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' -import type { AttachmentStore, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageRequestTarget, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' @@ -62,7 +62,7 @@ function requestVersion(ref: ImageAttachmentRef): RequestImageAttachment { function attachmentStore(readImageRequest: ( ref: ImageAttachmentRef, - policy: ImageRequestPolicy, + policy: ImageRequestTarget, signal?: AbortSignal, ) => Promise): AttachmentStore { return { readImageRequest, imageHostPath: () => undefined } as unknown as AttachmentStore @@ -105,7 +105,7 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => ( + const readImageRequest = vi.fn((value: ImageAttachmentRef, _target: ImageRequestTarget) => ( Promise.resolve(requestVersion(value)) )) const context = await toPiContext({ @@ -119,7 +119,7 @@ describe('toPiContext', () => { expect(readImageRequest).toHaveBeenCalledWith( attachment, - { maxPixels: 2048 * 2048, maxBytes: 1024 * 1024 }, + { width: 1, height: 1, maxBytes: 1024 * 1024 }, undefined, ) expect(context.messages[0]).toEqual({ @@ -141,7 +141,7 @@ describe('toPiContext', () => { width: 1, height: 1, } - const readImageRequest = vi.fn((value: ImageAttachmentRef, _policy: ImageRequestPolicy) => ( + const readImageRequest = vi.fn((value: ImageAttachmentRef, _target: ImageRequestTarget) => ( Promise.resolve(requestVersion(value)) )) const context = await toPiContext({ diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index c6fd5d6db3..fd917c7a66 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -5,7 +5,7 @@ import { AttachmentId, AttachmentStore, ImageVariantId } from '@deepseek-ai/dsh- import type { ImageAttachmentLimits, ImageAttachmentRef, - ImageRequestPolicy, + ImageRequestTarget, RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, @@ -91,7 +91,7 @@ async function harness(image?: StoredImageAttachment): Promise { return Promise.resolve(fixture) } - override readImageRequest(ref: ImageAttachmentRef, _policy: ImageRequestPolicy): Promise { + override readImageRequest(ref: ImageAttachmentRef, _target: ImageRequestTarget): Promise { if (ref.attachmentId !== fixture.ref.attachmentId) { return Promise.reject(new Error('unknown e2e attachment fixture')) } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index ce6ffd89f7..f226562e4d 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 8655f6d661..afd1be1ccd 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -147,6 +147,7 @@ function validateStarted( /** Validate every retry record already present in one loaded session. */ function validateSession(session: Session, fail: InvariantFailure): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = session.snapshotEvents() for (const [index, event] of events.entries()) { if (event.type === 'llm/retry') validateRetry(events.slice(0, index), event, fail) @@ -161,7 +162,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (event.type === 'llm/retry') validateRetry(session.snapshotEvents(), event, fail) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. else if (event.type === 'llm/retry-started') validateStarted(session.snapshotEvents(), event, fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index a96f7ea519..570ac9af86 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index d32368def6..f04bbc0539 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts index 280afb90b8..d09a22a02f 100644 --- a/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts +++ b/packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts @@ -194,6 +194,7 @@ describe('DeepSeek plugin package inventory', () => { await ctx.loader.create({ name: 'versioned-plugin/plugin.mjs' }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(composition).href } }) + await ctx.loader.await() const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }) expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([ diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 5b080f0800..44223f3a96 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 348213ef4d..2693af5913 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -229,7 +229,8 @@ export class TokenMeter extends Service { } while (state.consumedEvents < session.seq) { - // oxlint-disable-next-line typescript/no-non-null-assertion -- contiguous session seqs index the durable log + // Contiguous session seqs index the durable log; existing Session history read, migration deferred. + // oxlint-disable-next-line typescript/no-non-null-assertion, typescript/no-deprecated const event = session.eventAt(SessionSeq(state.consumedEvents))! this._foldEvent(state, event) state.consumedEvents = SessionLogOffset(state.consumedEvents + 1) diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 86977faa2f..6428068eb0 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index aeaa6822c6..4d8df70f5a 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 1ff7e5756d..f4df13c8a1 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 74afb59465..b9f167d303 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md -README.md: de646256888f1c271ad94a4c863338dfb02551e6 -README.zh.md: fe9aadfa00d68b5cea9d5c1f201731544b963b7e +README.md: 591b93c4bdb922151d246d1eebf46ad0b582cb01 +README.zh.md: 41938dc61b50eaa468cc1915f47bc371107c87e1 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index de64625688..591b93c4bd 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -67,7 +67,7 @@ Add one entry per server; nothing else is required. After the harness starts, th The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-mcp-client) is the exhaustive source for every accepted field. -After startup, the server's tools appear as `mcp____` — try a prompt that uses one. If the initial connection fails, the harness still starts but no tools from that server appear, and an error is logged; set `failOnStartupError: true` to make a startup failure abort the harness instead. +After startup, the server's tools appear as `mcp____` — try a prompt that uses one. If the initial connection fails, the harness still starts but no tools from that server appear, and an error is logged. Setting `failOnStartupError: true` rejects plugin activation; [app-boot's startup policy](../../boot/app-boot/README.md) still permits an optional MCP entry to fail without aborting the harness. ### Tool naming and coexistence diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index fe9aadfa00..41938dc61b 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-mcp-client)是每个受支持字段的穷尽式真源。 -启动后,服务器的工具会以 `mcp____` 形式出现——试着用一条提示词调用其中一个。如果初始连接失败,harness 仍会启动,但该服务器的工具不会出现,并会记录一条错误;设置 `failOnStartupError: true` 可让启动失败改为中止 harness。 +启动后,服务器的工具会以 `mcp____` 形式出现——试着用一条提示词调用其中一个。如果初始连接失败,harness 仍会启动,但该服务器的工具不会出现,并会记录一条错误。设置 `failOnStartupError: true` 会拒绝插件激活;[app-boot 的启动策略](../../boot/app-boot/README.zh.md)仍允许可选 MCP 配置项失败,而不中止 harness。 ### 工具命名与共存 diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 77e86da0d7..d5573b3f94 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml index 1a4d033b5b..aedd2d0e8e 100644 --- a/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml +++ b/packages/mcp/mcp-client/tests/fixtures/repeated-cursor.patch.yml @@ -1,9 +1,3 @@ -- id: headless-startup - disabled: true - -- id: headless-runner - disabled: true - - insert: - id: mcp-pagination-cycle name: '@deepseek-ai/dsh-mcp-client' diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 710f6eb629..d8f289b113 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -58,6 +58,7 @@ } }, "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 392f9193e7..e5910064de 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -23,6 +23,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import { brandString } from '@deepseek-ai/dsh-brand' import { z as zod } from 'zod' import type { ZodType } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' @@ -30,7 +31,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { defineTool } from '@deepseek-ai/dsh-tools' import { UserQuestionError } from '@deepseek-ai/dsh-user-questions' -import type { CommandId } from '@deepseek-ai/dsh-commands' +import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { PlanProjection, PlanUnitState } from './types.ts' @@ -223,6 +224,7 @@ export class PlanModeController extends Service { // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { commandCtx.commands.register({ + definitionId: brandString('@deepseek-ai/dsh-plan-mode'), name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true }, diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 72a319fc45..60389c8879 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -28,6 +28,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended plan-mode state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const seed = (session: Session): void => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(event, fail) } for (const session of ctx.sessions.list()) seed(session) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 2e3bd89949..118d112c52 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -620,7 +620,7 @@ describe('/plan', () => { const plainSteer = vi.fn() ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer expect(ctx.commands.list(plainAgent)).toEqual([ - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, + { definitionId: '@deepseek-ai/dsh-plan-mode', name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ]) const signal = new AbortController().signal diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 5856f67de8..6956c212c8 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md -README.md: 0b06026980e8d443e7704eb0c5bff42ef21fe265 -README.zh.md: d26921d64313186ab814b5cb4c2929839666801f +README.md: f6916023772d22163ce470e30efd255e400e2dc6 +README.zh.md: b4de89926ff3e91beb302c203660d23c34fc5989 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 0b06026980..f691602377 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -143,7 +143,7 @@ This section explains the design behind the roster and the standing mount; obser ### The mount audit -A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. +A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects an import or activation failure, an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. ### Authoring mechanics diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index d26921d643..b4de89926f 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -143,7 +143,7 @@ agent-presets: ### 挂载审计 -直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 +直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝导入或激活失败、无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 ### 创作机制 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 6c2d6ebcec..8a84a04167 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index 0173bc768a..dd047542ea 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -296,24 +296,28 @@ export function serviceForAgent( /** * Rows that did not reach a usable state, each rendered as one diagnostic line. * - * A row whose module failed to import or whose plugin threw already rejects the - * mount through the loader; what remains observable here is a row still waiting - * for a service the composition never supplies. + * Wait for the subtree, then report import failures, activation failures, and + * rows waiting for services the composition does not supply. * @param tree - the mounted subtree. * @returns one line per unusable row, empty when every enabled row is usable. */ -export function inactiveRows(tree: EntryTree): string[] { +export async function inactiveRows(tree: EntryTree): Promise { + await tree.await() const lines: string[] = [] for (const entry of tree.entries()) { if (entry.disabled) continue const fiber = entry.fiber - /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed, - so a settled tree never holds an enabled fiber-less entry; the branch exists - only because `Entry.fiber` is declared optional. */ if (fiber === undefined) { lines.push(`${entry.options.id} (${entry.options.name}): never started`) continue } + try { + await fiber.await() + } catch (error) { + const detail = mountDetail(error) + lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`) + continue + } const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined) if (missing.length > 0) { lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`) @@ -325,14 +329,8 @@ export function inactiveRows(tree: EntryTree): string[] { /** * The causes of `error` whose detail its own message does not already carry. * - * `AggregateError` names none of its causes in its own message, so its - * `errors` are the branches. The Loader's per-row wrapper takes the opposite - * approach: it appends `cause.message` to the message it builds and keeps the - * cause only as `error.cause`, so following a plain chain would print every - * line twice. That leaves exactly one lossy shape — a wrapped row whose cause - * is an `AggregateError`. Its message ends with the aggregate's own line and - * drops the `errors` behind it, which is how a failed group reports as - * "loader entries failed to apply" and names none of the rows that failed. + * Aggregate errors carry separate member messages. A wrapper can preserve the + * aggregate as its cause without including those messages in its own text. * @param error - the failure to read branches from. * @returns the branches to render beneath `error.message`, possibly empty. */ @@ -344,19 +342,12 @@ function detailBranches(error: Error): readonly unknown[] { /** * The reportable text of a mount failure. * - * The loader reports several failed rows as one `AggregateError`, whose own - * message names none of them; without flattening, a composition that fails on - * two rows says only "loader entries failed to apply" and the operator has - * nothing to act on. Nested groups indent under the row that owns them, so a - * composition failing inside a group still names the rows rather than the - * group alone. + * A plugin may reject with an aggregate or wrap one as its cause. Include its + * member messages beneath the row diagnostic so each failure is visible. * @param error - the value the mount rejected with. * @returns a single-line-per-cause description. */ function mountDetail(error: unknown): string { - /* v8 ignore next -- every path into the mount's catch throws an Error: the loader - wraps a row's thrown value before it propagates, and this module's own - rejections are Errors. The fallback keeps a hostile value readable. */ if (!(error instanceof Error)) return String(error) const branches = detailBranches(error) if (branches.length === 0) return error.message @@ -414,7 +405,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */ if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree') const { tree, fiber } = subtree - const unusable = inactiveRows(tree) + const unusable = await inactiveRows(tree) if (unusable.length > 0) { throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`) } diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index e6de991347..c4743e0ad1 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -15,7 +15,7 @@ import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh- import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { - COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent, + COMPOSITION_FILE, inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' import type { Config } from '@deepseek-ai/dsh-agent-presets' import type {} from '@deepseek-ai/dsh-agent-presets/types' @@ -242,6 +242,27 @@ describe('composing a child agent from its parent', () => { }) describe('rejecting a composition that cannot be used', () => { + it('reports import failures and arbitrary plugin rejections after eager settlement', async () => { + ctx.loader.builtins.stringFailure = () => { throw 'string rejection' } + ctx.loader.builtins.aggregateFailure = () => { + throw new AggregateError([ + new Error('first member'), + new Error('wrapped member', { cause: new AggregateError(['nested member'], 'nested aggregate') }), + ], 'aggregate rejection') + } + await ctx.loader.root.update([ + { id: 'missing', name: 'cordis:missingBuiltin' }, + { id: 'disabled', name: 'cordis:missingBuiltin', disabled: true }, + { id: 'string', name: 'cordis:stringFailure' }, + { id: 'aggregate', name: 'cordis:aggregateFailure' }, + ]) + expect(await inactiveRows(ctx.loader)).toEqual([ + 'missing (cordis:missingBuiltin): never started', + 'string (cordis:stringFailure): string rejection', + 'aggregate (cordis:aggregateFailure): aggregate rejection\n- first member\n- wrapped member\n - nested member', + ]) + }) + it('refuses to mount into a context that carries no agent scope', async () => { await expect(ctx.agentPresets.mount(ctx, 'standard')) .rejects.toThrow(/unscoped context/) @@ -255,20 +276,13 @@ describe('rejecting a composition that cannot be used', () => { }) it('names every failed row, not just the count', async () => { - // The Loader folds several failed rows into one AggregateError whose own - // message names none of them; unflattened, the operator is told only that - // "loader entries failed to apply" and has nothing to act on. await expect(agentOn(ctx, 'sess-two-broken', 'two-broken')) .rejects.toThrow(/first-refuses[\s\S]*second-refuses/) }) it('names the rows inside a failed group, not the group alone', async () => { - // The Loader's per-row wrapper keeps only `cause.message`, so a group's - // own AggregateError arrives with its `errors` reachable through `cause` - // alone. Reading the message stops at "loader entries failed to apply" - // and names neither row that actually refused. await expect(agentOn(ctx, 'sess-nested-broken', 'nested-broken')) - .rejects.toThrow(/outer[\s\S]*inner-first[\s\S]*inner-second/) + .rejects.toThrow(/inner-first[\s\S]*inner-second/) }) it('names the unresolved service when a row never activates', async () => { diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index b17e8bda02..ec116bd3dd 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index cd4668a70a..2d10a8814f 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 1e91e6eba2..76bf039d88 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index e1589090a3..686d4266ba 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 6f3d5ca654..62ae2446ae 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -23,6 +23,7 @@ function validateEvent(event: SessionEvent, fail: InvariantFailure): void { /** Install validation for loaded and newly appended sandbox modes. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) validateEvent(event, fail) } ctx.on('internal/dispatch', (_mode, eventName, args) => { diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 5c9827262d..b5f376607c 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 9fdfb59152..05e539d567 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 142ef6a2dc..eb43fbbed4 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/src/index.ts b/packages/schedule/schedule/src/index.ts index 9364fc53e0..7049246bdf 100644 --- a/packages/schedule/schedule/src/index.ts +++ b/packages/schedule/schedule/src/index.ts @@ -55,6 +55,7 @@ export function apply(ctx: Context): void { const cleanup: OwnerCleanup = agent.ctx.effect(() => { const disposeTools = registerScheduleTools(ctx, agent.ctx, agent, () => { runtime.requestDrive() }) const stopStatus = agent.ctx.on('agent/status', ({ status }) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (status === 'idle' && agent.session.snapshotEvents().some(event => event.type === 'schedule/change')) { runtime.requestDrive() } diff --git a/packages/schedule/schedule/src/invariant.ts b/packages/schedule/schedule/src/invariant.ts index 248e7df710..0d5a89f926 100644 --- a/packages/schedule/schedule/src/invariant.ts +++ b/packages/schedule/schedule/src/invariant.ts @@ -30,15 +30,18 @@ function validate(events: readonly SessionEvent[], fail: InvariantFailure): void /** Install replay and pre-append validation for the owned event stream. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { for (const session of ctx.sessions.list()) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validate(session.ownEvents(), fail) } ctx.on('session/created', (session) => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validate(session.ownEvents(), fail) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (event.type !== 'schedule/change') return + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. validate([...session.ownEvents(), event], fail) }, { global: true }) }, { inject: ['sessions'] }) diff --git a/packages/schedule/schedule/src/runtime.ts b/packages/schedule/schedule/src/runtime.ts index 5b141f5728..1574574c3d 100644 --- a/packages/schedule/schedule/src/runtime.ts +++ b/packages/schedule/schedule/src/runtime.ts @@ -205,6 +205,7 @@ export class ScheduleRuntime { /** Fold the current exact runtime suffix and contain a corrupt durable stream. */ private readFolded(): FoldedSchedules | undefined { try { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldScheduleEvents(this.agent.session.ownEvents()) } catch (error: unknown) { this.faulted = true diff --git a/packages/schedule/schedule/src/tools.ts b/packages/schedule/schedule/src/tools.ts index c4ac651634..83162ae2a9 100644 --- a/packages/schedule/schedule/src/tools.ts +++ b/packages/schedule/schedule/src/tools.ts @@ -221,6 +221,7 @@ function inputError(error: ScheduleInputError): ScheduleToolError { /** Fold only after a successful preflight, mapping corruption to a stable value. */ function foldForTool(agent: Agent): ReturnType | ScheduleToolError { try { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldScheduleEvents(agent.session.ownEvents()) } catch (error: unknown) { return error instanceof ScheduleLogError ? corruptLogError() : internalError() diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index c668128c2f..272fbab849 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index c56b34204f..e65cf001d0 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 3a9b9123ab..56d1e7cad5 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 8992ab79e9..f63702c964 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index abc4932b10..f907054d1a 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -1,6 +1,7 @@ /** Session-log download command and Host-owned streaming route. */ import type { Context } from '@deepseek-ai/cordis' +import type { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import Schema from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-attachment' @@ -76,6 +77,7 @@ const REQUESTED: CommandResult = { */ export function apply(ctx: Context, config: Config = {}): void { ctx.effect(() => ctx.commands.register({ + definitionId: brandString('@deepseek-ai/dsh-session-log-export'), name: 'export', description: 'Download this Session log as a ZIP archive', handler: invocation => Promise.resolve(invocation.rawInput.trim() === '' diff --git a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts index 1d61e0ef3a..b6c4a6237e 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts @@ -61,6 +61,7 @@ describe('session-log-download real Loader composition', () => { .create(SessionId('loader-session-export'), { meta: { createdAt: 1 } }) const agent = { session, status: 'idle', options: {} } as unknown as Agent expect(context.commands.list(agent)).toContainEqual({ + definitionId: '@deepseek-ai/dsh-session-log-export', name: 'export', description: 'Download this Session log as a ZIP archive', }) const execution = await context.commands.execute(agent, '/export', [], new AbortController().signal) diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 198bd6a8f4..ed79f3840e 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 0fd390ee6a..e0b24b3c39 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -871,6 +871,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return observeSession(session.header, session.inheritedEventCount, session.snapshotEvents()) } diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index f925820e48..e383b1b413 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index a6e44c85ad..cd01ca8234 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -243,6 +243,7 @@ function projectSource( } function sourceLive(session: Session): LogicalSessionSource { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return { header: session.header, events: session.snapshotEvents() } } @@ -298,6 +299,7 @@ function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), inheritedEventCount: session.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. events: session.snapshotEvents().map(event => structuredClone(event)), } } diff --git a/packages/session-query/session-query/src/observation.ts b/packages/session-query/session-query/src/observation.ts index 7ef86cebd4..8c08108775 100644 --- a/packages/session-query/session-query/src/observation.ts +++ b/packages/session-query/session-query/src/observation.ts @@ -289,6 +289,7 @@ export class SessionObservationReader { header: session.header, inheritedEventCount: session.inheritedEventCount, get events() { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. materialized ??= session.snapshotEvents(SessionLogOffset(0), seq) return materialized }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 2296fb8ec4..1ad9a77b5c 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 50cb01f60f..a817b06e4e 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index abc714542c..f3ea35a6c8 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-catalog", "description": "Build-static first-party Session format codec and migration catalog", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v0-to-v1/package.json b/packages/session/session-format-v0-to-v1/package.json index e2e6285992..ca5d77d640 100644 --- a/packages/session/session-format-v0-to-v1/package.json +++ b/packages/session/session-format-v0-to-v1/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v0-to-v1", "description": "Frozen released-v0 Session codec and identity migration to v1", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v1-to-v2/package.json b/packages/session/session-format-v1-to-v2/package.json index b2e9861786..a44eef9856 100644 --- a/packages/session/session-format-v1-to-v2/package.json +++ b/packages/session/session-format-v1-to-v2/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v1-to-v2", "description": "Frozen released-v1 Session codec and assistant-stream migration to v2", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v2-to-v3/package.json b/packages/session/session-format-v2-to-v3/package.json index 8402b49575..637cd4f0fe 100644 --- a/packages/session/session-format-v2-to-v3/package.json +++ b/packages/session/session-format-v2-to-v3/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v2-to-v3", "description": "Streaming system-prompt, canonical-envelope and PTC migration into V3", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format/package.json b/packages/session/session-format/package.json index bf064258a8..4eaefdbea2 100644 --- a/packages/session/session-format/package.json +++ b/packages/session/session-format/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format", "description": "Streaming adjacent Session format migration machinery", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 19af009069..4cbdc9e4f1 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/src/index.ts b/packages/session/session-log-deepseek/src/index.ts index 4c35021c8d..1147927bd5 100644 --- a/packages/session/session-log-deepseek/src/index.ts +++ b/packages/session/session-log-deepseek/src/index.ts @@ -120,6 +120,7 @@ export function acceptedThrough(session: Session): SessionSeqCursor { const length = session.seq const start = previous?.scannedEvents ?? SessionLogOffset(0) for (let index = start; index < length; index++) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(index)) if (event === undefined) { throw new Error(`session-log-deepseek: missing event ${String(index)} below captured length ${String(length)}`) @@ -164,9 +165,11 @@ export function apply(ctx: Context, config: Config): void { if (session === undefined) return undefined const afterSeq = acceptedThrough(session) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const snapshot = session.snapshotEvents() const throughSeq = snapshot.at(-1)?.seq if (throughSeq === undefined) return undefined + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1)) const value: DeepSeekSessionLogExtension = { version: 1, diff --git a/packages/session/session-log-deepseek/src/invariant.ts b/packages/session/session-log-deepseek/src/invariant.ts index c855666ded..f01d5aef9e 100644 --- a/packages/session/session-log-deepseek/src/invariant.ts +++ b/packages/session/session-log-deepseek/src/invariant.ts @@ -44,6 +44,7 @@ function validateDeliveryAccepted(session: Session, event: SessionEvent<'session /** Validate acceptance watermarks already present in one Session. */ function validateSession(session: Session, fail: InvariantFailure): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'session-log-deepseek/delivery-accepted') validateDeliveryAccepted(session, event, fail) } diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 5949c1cfea..2756ed8ebd 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index e6dc547141..1562f0f190 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index b9922285fd..bd1672e91a 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 4b5fdbbbf1..6f426ab889 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index d29c5bb021..6d28631ddc 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -619,6 +619,7 @@ export class SessionProjectionRegistry extends Service { registration.def, session.header, session.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents(), ) registration.cells.set(session, cell) @@ -637,6 +638,7 @@ export class SessionProjectionRegistry extends Service { ): void { if (cell.observedSeq >= throughSeq) return for (let seq = cell.observedSeq + 1; seq <= throughSeq; seq++) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = session.eventAt(SessionSeq(seq)) if (event === undefined || event.seq !== seq) { throw new Error(`session projection ${JSON.stringify(def.key)} cannot advance across missing seq ${String(seq)}`) @@ -663,6 +665,7 @@ export class SessionProjectionRegistry extends Service { registration.def, session.header, session.inheritedEventCount, + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(event.seq)), ) registration.cells.set(session, cell) diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index b604c54f83..c62eb3b449 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index ac0eea1446..2da6d75acb 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 43c93fb8ec..0722845a3b 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -246,6 +246,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { ctx.on('session/event', (session, event) => { if (!isFeedback(session, event)) return // Only the canonical appended event authorizes this exact prefix. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. if (session.eventAt(event.seq) !== event) { ctx.logger.warn(NON_CANONICAL_EVENT_WARNING) return diff --git a/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts index 6bc115af75..af28e1e892 100644 --- a/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/session/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -73,7 +73,7 @@ function eventTypes(captures: OtlpCapture[]): string[] { } describe('session-telemetry-otel through the production headless profile', () => { - it('rejects FULL before any session can be uploaded', async () => { + it('continues the headless task without telemetry when FULL is rejected', async () => { const { stdout, stderr } = await runLoaderSmoke({ label: 'session-telemetry-otel rejected FULL loader smoke', tempDirPrefix: 'telemetry-otel-full-e2e-', @@ -82,8 +82,8 @@ describe('session-telemetry-otel through the production headless profile', () => configPath, tsconfigPath: repoTsconfig, env: { DSH_TELEMETRY_E2E_MODE: 'FULL' }, - expectedExitCode: 1, }) + expect(stdout + stderr).toContain('warning: 1 entry did not activate') expect(stdout + stderr).toContain('FULL') }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 195062fad7..1278879299 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index 773aecb923..14c9916c64 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -153,6 +153,7 @@ export class SessionTelemetryCoordinator { ?? (this.options.includeHistory === true || session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1)) // Containment is PER EVENT: one rejected record is withheld fail-closed // while the rest of the historical replay proceeds. + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents(SessionLogOffset(cursor + 1))) { if (throughSeq !== undefined && event.seq > throughSeq) break this.contain(() => { diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 3d1ff5afc1..4ceac7914e 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index ac6538d538..e88246e9e6 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index abddcf8b71..b7eef1d212 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 87124a468c..a7e9894f61 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 3e36f1d588..014310b529 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -383,6 +383,7 @@ export class SessionTitleService extends Service { * @returns latest title snapshot, or `undefined` before eligible input. */ get(session: Session): SessionTitleSnapshot | undefined { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. return foldSessionTitle(session.snapshotEvents()) } @@ -593,6 +594,7 @@ export class SessionTitleService extends Service { this.assertCurrent(session, work) await this.ensureFallback(session) this.assertCurrent(session, work) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq) const result = await work.registration.provider.generate({ session, diff --git a/packages/session/session-title/src/invariant.ts b/packages/session/session-title/src/invariant.ts index e12cd6ca25..986b95f81d 100644 --- a/packages/session/session-title/src/invariant.ts +++ b/packages/session/session-title/src/invariant.ts @@ -46,6 +46,7 @@ function validate( fail(`session/title event ${String(event.seq)} repeats message seq ${checked}`) } seen.add(checked) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const cited = checked < event.seq ? session.eventAt(checked) : undefined if (cited?.type !== 'user/message' || cited.data.source.kind !== 'user') { fail(`session/title event ${String(event.seq)} message seq ${checked} must name an earlier human user/message`) @@ -55,6 +56,7 @@ function validate( const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const validateExisting = (session: Session): void => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { if (event.type === 'session/title') validate(session, event, fail) } diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index ca2196013a..14153bed1f 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 52779af384..6ab3a5b2b0 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index cb5e633138..51acd5bec2 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index ed2bfdbdd0..b5ce1e5135 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index b66267f811..bfe649350b 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 0e2d994d9a..6925fc8509 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 2140443825..ad46833902 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index bc3f4bfe98..ddc0df2fd2 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 388c903035..57a8573d6b 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index b42c104c3a..8551bd66d5 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 35cdadb6da..c6d4a937b1 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index a464c68997..ae0d009f52 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 064cf4d4c5..30642f2ef5 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index c9ad804b17..dc0d6728e2 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index e13e47cfa1..98ce2aecec 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 67b9a14e2c..01be87e133 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 916f22c0c0..a12fcde6eb 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 3465cd843c..0099332fa2 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -362,6 +362,7 @@ function catalogHistory(agent: Agent): { visibleDigest?: string; published: bool const visible = new Set(agent.session.surface.nodes) let published = false for (let index = agent.session.seq - 1; index >= 0; index -= 1) { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const event = agent.session.eventAt(SessionSeq(index)) if (event === undefined) { throw new Error(`skill catalog cannot read seq ${String(index)} below the current Session length`) diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 57570f70cf..510acaf6b1 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 816effcb20..e6e1dee090 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 0c76ff0273..b4a3c32097 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index cd0bd016c3..8fe6912da9 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 64a6bb9e71..9d21ecfec2 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 0f60db9f4e..d8dfc56910 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 1bdb7236d6..a418159f5c 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 024d961e53..45e0f6c99c 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 1de31c00ae..cea3e4536f 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1412,17 +1412,27 @@ describe('dsh-subagent-acp', () => { args: [mockServer], permission: 'reject', env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready }, - disposeEofGraceMs: 150, - disposeGraceMs: 150, + // The graces are wall-clock budgets for the managed scope's teardown on a + // shared host. At 150ms the hosted image escalated while the scope could + // not take the signal and systemctl failed the kill ("Failed to send + // signal SIGKILL to auxiliary processes: Invalid argument"), surfacing as + // a teardown failure the configuration never asked for. The case asserts + // that config graces reach the real run, so the value only has to clear + // the host's scope handling. + disposeEofGraceMs: 5_000, + disposeGraceMs: 5_000, }) const run = await ctx.subagents.start('acp', request()) expect(start).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({ - disposeEofGraceMs: 150, - disposeGraceMs: 150, + disposeEofGraceMs: 5_000, + disposeGraceMs: 5_000, })) await waitForFile(ready, task.timeout) await expect(run.dispose()).resolves.toBeUndefined() - }) + // The trapped child refuses stdin EOF, so the ladder waits out the EOF + // grace and then the SIGTERM grace (10s) before the SIGKILL settles it. + // That fixed cost is above the 5000ms default the local unit entry grants. + }, 30_000) it('rejects a dispose grace outside the Node timer range at load', async () => { for (const bad of [ diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 9bd02b02a4..19d90f5a92 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 62b8a989b3..c434b0b52d 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 7180f10662..1a378f53f2 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index b9fa8c55c0..ba26c0c69c 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -6,7 +6,7 @@ * quiescent disposal are all exercised end to end. No model, no key. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -751,19 +751,17 @@ describe('dsh-subagent-dsh-sdk provider', () => { cwd: process.cwd(), provider: 'p', model: 'm', - // The fake dies as soon as the prompt arrives: FAKE_HANG_PROMPT plus a - // short-lived process is simulated by killing via dispose below instead; - // here use FAKE_MALFORMED to make the prompt reply violate the protocol. env: { FAKE_MALFORMED_PROMPT: '1' }, - shutdownTimeoutMs: 100, - disposeEofGraceMs: 200, - disposeGraceMs: 200, + shutdownTimeoutMs: DEFAULT_SHUTDOWN_TIMEOUT_MS, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: (error) => { seen.push(error.message) throw new Error('sink failure must be contained') }, } const run = await startSdkRun(request(), spec) + onTestFinished(() => run.dispose()) const result = await run.result expect(result.stopReason).toBe('error') expect(result.diagnostic).toBe( diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index e5cf64fe0c..22d147631e 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/src/index.ts b/packages/subagent/subagent-fork-in-process/src/index.ts index 0e0311ebb5..0e69915b60 100644 --- a/packages/subagent/subagent-fork-in-process/src/index.ts +++ b/packages/subagent/subagent-fork-in-process/src/index.ts @@ -46,6 +46,7 @@ export const Config: z = z.object({ * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ function completedTurnPrefix(parent: Agent): SessionEvent[] { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const events = parent.session.snapshotEvents() const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 3a256d0f24..2c3ede08bd 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index c0886dc104..91348b2da9 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -215,6 +215,7 @@ function readResult( cancelled: boolean, structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const own = child.session.snapshotEvents(boundary) // `droppedUnrun` is deliberately unread: a one-shot prompt is claimed by its // awaited first turn almost immediately, and the owner's own teardown is the diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 0f6449cb2c..a7c5e2f2a6 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 2ba82d9c88..ea19ef9c7b 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index ba2db8e62c..adeba106f6 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -198,6 +198,7 @@ export function createActivationObserver( emit('subagent/start', identity, parent) }, capture: (child: Agent): void => { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. const own = child.session.snapshotEvents(boundary) const output = finalAssistantOutput(own) captured = { diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index ede810039a..2272a1c6a2 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 9eeda985de..aba9a126eb 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 5ca770d7d7..4e8cc4b6b1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -619,6 +619,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { } const selectForSession = (target: Session): ModelSelectionPolicy | undefined => { const freshSession = target.firstLiveSeq === 0 + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed' let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target) if (allowedModels === undefined) { diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 5cc4ae4c49..f4573b93cc 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md -README.md: 03856c32e6308a1d075ff41b879da144cc772843 -README.zh.md: d1fb5881396963bf5a8d7cb4f44cabae3d1e5a24 +README.md: 3222d7bf9b9b498baf939212ebbf7da9bf599d76 +README.zh.md: e8d2ddc9398ffe186260a1b4ed3a0cd116ff8afb diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 03856c32e6..3222d7bf9b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -52,7 +52,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. -Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty. +Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. ### What can go wrong diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index d1fb588139..e8d2ddc939 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。 -Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空。 +Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。 ### 可能出错的地方 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 1b17ce8366..e9ee873f97 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/src/linux-scope.ts b/packages/subprocess/subprocess-local/src/linux-scope.ts index d5f28b5fb2..c018dc01a3 100644 --- a/packages/subprocess/subprocess-local/src/linux-scope.ts +++ b/packages/subprocess/subprocess-local/src/linux-scope.ts @@ -178,6 +178,7 @@ class LinuxScopeStartup { class SystemdScopeOwner implements BoundProcessOwner { private establishment: 'pending' | 'established' = 'pending' private stopped = false + private terminationRequested = false private observation: Promise | undefined private killFailure: Error | undefined private wakeGeneration = 0 @@ -195,6 +196,7 @@ class SystemdScopeOwner implements BoundProcessOwner { signal(signal: 'SIGTERM' | 'SIGKILL'): void { if (this.stopped) return + this.terminationRequested = true if (this.direct.running()) this.startup.terminationSignals.add(signal) this.observeRequestConsumption() const directFallbackRequired = this.establishment === 'pending' @@ -256,7 +258,32 @@ class SystemdScopeOwner implements BoundProcessOwner { return true } - private parseUnitState(stdout: string): { loadState: string; activeState: string } { + /** + * Prove an active unit with no processes is the empty managed range rather + * than a launch still placing its payload. systemd ends a scope only on the + * populated-to-empty transition, so a payload killed before it entered the + * cgroup leaves the unit active forever. Requested termination plus a + * departed client makes that leftover conclusive: the client forked every + * process it will ever fork. + */ + private emptyRange(tasksCurrent: number | undefined): boolean { + return this.terminationRequested && tasksCurrent === 0 && !this.direct.running() + } + + /** Release a leftover empty scope so the transient unit is collected and cannot accumulate. */ + private releaseEmptyRange(): void { + try { + this.runSync(this.systemctl, ['--user', 'stop', this.unit], { + env: managerEnvironment(), + stdio: 'ignore', + timeout: SYSTEMCTL_TIMEOUT_MS, + }) + } catch { + // The range is already empty; a failed cleanup leaves only the transient unit. + } + } + + private parseUnitState(stdout: string): { loadState: string; activeState: string; tasksCurrent: number | undefined } { const values = new Map() for (const line of stdout.split(/\r?\n/u)) { if (line === '') continue @@ -272,10 +299,21 @@ class SystemdScopeOwner implements BoundProcessOwner { } const loadState = values.get('LoadState') const activeState = values.get('ActiveState') - if (values.size !== 2 || loadState === undefined || activeState === undefined) { + // The manager prints this sentinel for a property the unit does not carry. + const reportedTasks = values.get('TasksCurrent') + const tasksCurrent = reportedTasks === '[not set]' ? undefined : reportedTasks + if (values.size !== (reportedTasks === undefined ? 2 : 3) + || loadState === undefined || activeState === undefined) { throw new Error(`systemctl returned incomplete state for ${this.unit}: ${JSON.stringify(stdout.trim())}`) } - return { loadState, activeState } + if (tasksCurrent !== undefined && !/^\d+$/u.test(tasksCurrent)) { + throw new Error(`systemctl returned a non-numeric TasksCurrent for ${this.unit}: ${JSON.stringify(tasksCurrent)}`) + } + return { + loadState, + activeState, + tasksCurrent: tasksCurrent === undefined ? undefined : Number(tasksCurrent), + } } private async rangeActive(): Promise { @@ -286,10 +324,11 @@ class SystemdScopeOwner implements BoundProcessOwner { this.unit, '--property=LoadState', '--property=ActiveState', + '--property=TasksCurrent', ]) const output = `${result.stdout}\n${result.stderr}` if (result.status === 0) { - const { loadState, activeState } = this.parseUnitState(result.stdout) + const { loadState, activeState, tasksCurrent } = this.parseUnitState(result.stdout) if (loadState === 'not-found' && activeState === 'inactive') return this.absentUnit() if (loadState !== 'loaded') { throw new Error( @@ -302,6 +341,10 @@ class SystemdScopeOwner implements BoundProcessOwner { throw new Error(`systemctl returned unknown ActiveState for ${this.unit}: ${JSON.stringify(activeState)}`) } if (this.killFailure !== undefined) throw this.killFailure + if (this.emptyRange(tasksCurrent)) { + this.releaseEmptyRange() + return false + } return true } if (!MISSING_UNIT.test(output)) { diff --git a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts index ab69f4483b..8233e60a2f 100644 --- a/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts +++ b/packages/subprocess/subprocess-local/tests/linux-scope.spec.ts @@ -84,6 +84,20 @@ function unitState(loadState: string, activeState: string) { return { status: 0, stdout: `LoadState=${loadState}\nActiveState=${activeState}\n`, stderr: '' } } +function activeUnitWithTasks(tasks: string) { + return { status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n`, stderr: '' } +} + +/** Deny every real process-group signal so a fake child never reaches a live host group. */ +function denyProcessGroups(): void { + vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('missing process group') }) +} + +/** Record the systemctl invocations a scope owner makes, succeeding unless a case overrides it. */ +function recordingSystemctl() { + return vi.fn((_command: string, _args: readonly string[]) => ({ status: 0, stdout: '', stderr: '' })) +} + function spec() { return { argv: ['tool', 'literal arg'], @@ -446,6 +460,7 @@ describe('Linux scope establishment and quiescence', () => { ['LoadState=loaded\nLoadState=loaded\nActiveState=active\n', 'duplicate LoadState'], ['LoadState=loaded\n', 'incomplete state'], ['LoadState=loaded\nActiveState=inactive\nOther=value\n', 'incomplete state'], + ['LoadState=loaded\nActiveState=active\nTasksCurrent=0\nOther=value\n', 'incomplete state'], ] as const) { const launched = launch(async () => ({ status: 0, stdout, stderr: '' })) await expect(launched.result.owner.waitForExit()).rejects.toThrow(message) @@ -453,6 +468,68 @@ describe('Linux scope establishment and quiescence', () => { } }) + it('rejects a manager process count that is neither numeric nor the unset sentinel', async () => { + const launched = launch(async () => activeUnitWithTasks('many')) + await expect(launched.result.owner.waitForExit()).rejects.toThrow('non-numeric TasksCurrent') + launched.result.owner.cleanup?.() + }) + + it('releases an active scope left with no processes once its client has gone', async () => { + // Regression: the manager's empty cgroup never ends this unit on its own. + denyProcessGroups() + const spawnSync = recordingSystemctl() + const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never }) + launched.result.owner.signal('SIGKILL') + launched.child.exit(null, 'SIGKILL') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(spawnSync.mock.calls.map(call => call[1])).toEqual([ + ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', expect.stringMatching(/\.scope$/u)], + ['--user', 'stop', expect.stringMatching(/\.scope$/u)], + ]) + launched.result.owner.cleanup?.() + }) + + it('concludes the empty range even when releasing the leftover scope fails', async () => { + denyProcessGroups() + const spawnSync = recordingSystemctl() + .mockImplementationOnce(() => ({ status: 0, stdout: '', stderr: '' })) + .mockImplementationOnce(() => { throw new Error('systemctl is gone') }) + const launched = launch(async () => activeUnitWithTasks('0'), { spawnSync: spawnSync as never }) + launched.result.owner.signal('SIGKILL') + launched.child.exit(null, 'SIGKILL') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + launched.result.owner.cleanup?.() + }) + + it('keeps waiting while the client still owns an active scope with no processes', async () => { + denyProcessGroups() + const spawnSync = recordingSystemctl() + const states = [activeUnitWithTasks('0'), unloadedUnit()] + const launched = launch(async () => states.shift() ?? unloadedUnit(), { + spawnSync: spawnSync as never, + }) + launched.result.owner.signal('SIGTERM') + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(spawnSync.mock.calls.map(call => call[1]?.[1])).toEqual(['kill']) + launched.result.owner.cleanup?.() + }) + + it('keeps waiting for an active empty scope no termination has requested', async () => { + const states = [activeUnitWithTasks('0'), unloadedUnit()] + const launched = launch(async () => states.shift() ?? unloadedUnit()) + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(launched.spawnSync).not.toHaveBeenCalled() + launched.result.owner.cleanup?.() + }) + + it('treats an unset process count as unknown and keeps waiting', async () => { + const states = [activeUnitWithTasks('[not set]'), unloadedUnit()] + const launched = launch(async () => states.shift() ?? unloadedUnit()) + await expect(launched.result.owner.waitForExit()).resolves.toBeUndefined() + expect(launched.spawnSync).not.toHaveBeenCalled() + launched.result.owner.cleanup?.() + }) + it('keeps signal failures scoped to final kill proof and stays idempotent after stop', async () => { const spawnSync = vi.fn() .mockReturnValueOnce({ status: 1, stdout: '', stderr: '' }) diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 4ca9f64439..a13f589de1 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -393,14 +393,16 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const service = ctx.subprocess as InstanceType + // Pins the containment choice: with the host's native scope a mocked PTY + // exit races the scope bootstrap. + service.internals = { platform: 'darwin' } const handle = await ctx.subprocess.spawnTerminal({ argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, }) expect((service as unknown as { terminals: Set }).terminals.size).toBe(1) exitListener?.({ exitCode: 0 }) await handle.done - await new Promise(resolve => setImmediate(resolve)) - expect((service as unknown as { terminals: Set }).terminals.size).toBe(0) + await expect.poll(() => (service as unknown as { terminals: Set }).terminals.size).toBe(0) await fiber.dispose() } finally { vi.doUnmock('node-pty') @@ -600,6 +602,9 @@ describe('LocalSubprocessRuntime', () => { ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime) const alive = new Set([124]) + // Pins the containment choice: with the host's native scope a mocked PTY + // exit races the scope bootstrap. + ;(ctx.subprocess as InstanceType).internals = { platform: 'darwin' } ;(ctx.subprocess as InstanceType).terminalInspector = { foregroundPgid: () => 123, isStdinWaiting: () => false, @@ -615,9 +620,11 @@ describe('LocalSubprocessRuntime', () => { const handle = await ctx.subprocess.spawnTerminal({ argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1, }) + const terminate = vi.spyOn(handle, 'terminate') exitListener?.({ exitCode: 0 }) await handle.done - await new Promise(resolve => setTimeout(resolve, 10)) + await expect.poll(() => terminate.mock.calls.length).toBe(1) + await expect(terminate.mock.results[0]?.value).rejects.toThrow('surviving pids: 124') expect((ctx.subprocess as unknown as { terminals: Set }).terminals.size).toBe(1) await fiber.dispose() expect(disposalErrors).toHaveLength(1) @@ -875,10 +882,26 @@ describe('LocalSubprocessRuntime', () => { const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) // Dispose before the rejection continuation removes the handle from the - // live set, so teardown itself must swallow the rejected done. + // live set, so teardown itself must swallow the rejected done. Two + // settlements are valid and the winner is a race: a bootstrap that + // publishes its pre-exec failure rejects with that failure, and a teardown + // that stops the bootstrap first settles as the requested termination — + // the recorded failure only outranks the stop when it was published before + // the stop landed. const handle = ctx.subprocess.spawn(spec('true', { cwd: '/nonexistent-dir-dsh-subprocess-test' })) await fiber.dispose() - await expect(handle.done).rejects.toThrow() + const settlement = await handle.done.then( + outcome => ({ kind: 'stopped' as const, outcome }), + (error: unknown) => ({ kind: 'failed' as const, error }), + ) + if (settlement.kind === 'failed') { + expect(settlement.error).toBeInstanceOf(Error) + } else { + // Only the Linux scope records a stop this way: the win32 job owner + // rejects a cancelled start and the fallback launcher rejects the ENOENT, + // so neither can produce the stopped branch. + expect(settlement.outcome.signal).toBe('SIGTERM') + } }) it('loading a second implementation throws (one processes service per context — cordis standard)', async () => { diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 2f768a7e84..8186220c96 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index c3e2823e96..0338895db5 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Shared low-level Win32 process, stdio, and Job Object primitives", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index a00d58715b..619cd97da3 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 9800a79d54..2cf46dd9c2 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -330,7 +330,9 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { timeoutMs: 8_000, }, 'pwsh') const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) - expect(created.motd).toContain('dsh> ') + // stdin_read can precede delivery of the printable prompt to the PTY reader. + await expect.poll(() => ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text, + { timeout: 8_000 }).toContain('dsh> ') const releaseFile = join(root, 'release-command') // Hold the command across the silence settlement without relying on host load. diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index a3c3d2fe0e..6ae9f278ee 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 1d4f1073e2..b9467f6204 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/README.i18n.yaml b/packages/test-support/README.i18n.yaml index d6c6102a5e..d123f288aa 100644 --- a/packages/test-support/README.i18n.yaml +++ b/packages/test-support/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/README.md -README.md: 787ebfeb16cd2b07f183cf5b4ab5adda8a95156b -README.zh.md: 3c98d99b71616e0e23140e736189f7b1768d2bcf +README.md: d3bd4bbff218227d4b0423f7f1287368e7f87a99 +README.zh.md: db451041f9a3cd3459aa8e591e10f1720e358557 diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 787ebfeb16..d3bd4bbff2 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -27,6 +27,7 @@ The test-support group gives repository tests deterministic, keyless ways to exe | [`session-snapshot`](session-snapshot/README.md) | Provides session-log snapshot support and protocol adapters for profile-driven tests | | [`agent-loop-testkit`](agent-loop-testkit/README.md) | Provides the shared prerequisite services for tests that exercise the concrete AgentLoop | | [`client-runtime`](client-runtime/README.md) | Provides the jsdom slot test bench for browser feature specs | +| [`remote-mock`](remote-mock/README.md) | Provides the endpoint-named Typert Remote mock and the Connection carrier face whole-client specs install | | [`loader-smoke`](loader-smoke/README.md) | Boots Loader-composed applications and drives fixture turns for smoke tests | | [`llm-mock-server`](llm-mock-server/README.md) | Provides a scriptable OpenAI-compatible fault server for recovery tests | | [`llm-replay`](llm-replay/README.md) | Replays recorded model streams for keyless tests and demos | diff --git a/packages/test-support/README.zh.md b/packages/test-support/README.zh.md index 3c98d99b71..db451041f9 100644 --- a/packages/test-support/README.zh.md +++ b/packages/test-support/README.zh.md @@ -27,6 +27,7 @@ test-support 组为仓库测试提供确定且无须密钥的真实产品测试 | [`session-snapshot`](session-snapshot/README.zh.md) | 为 profile 驱动的测试提供 session-log 快照支持与协议适配器 | | [`agent-loop-testkit`](agent-loop-testkit/README.zh.md) | 为运行具体 AgentLoop 的测试提供共享先决服务 | | [`client-runtime`](client-runtime/README.zh.md) | 为浏览器功能测试提供 jsdom slot 测试台 | +| [`remote-mock`](remote-mock/README.zh.md) | 为整体客户端测试提供端点具名的 Typert Remote mock 与它们安装的 Connection 载体面 | | [`loader-smoke`](loader-smoke/README.zh.md) | 启动由 Loader 组合的应用并驱动 fixture(测试前置数据)轮次以执行冒烟测试 | | [`llm-mock-server`](llm-mock-server/README.zh.md) | 为恢复测试提供可通过脚本控制的 OpenAI 兼容故障服务器 | | [`llm-replay`](llm-replay/README.zh.md) | 为无密钥测试与演示回放已记录的模型流 | diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index da125d7e3e..e476648bfa 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Prerequisite mounting, production AgentLoop drivers, and Inbox stubs for tests", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 1fc388204f..534cc2596a 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/test-support/client-runtime/README.md -README.md: 0b6caf7237520ca1a10fe0bbdf3c12c6d793461f -README.zh.md: ce78a0ad623247ad769e0ea91895daa1db8e7f1b +README.md: 20f6454012d0d8e4c78d9a0ea0df960d22a71f5a +README.zh.md: d9c6888f17df3f37e699bb6be0f7ee44ce82d373 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 0b6caf7237..20f6454012 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-test-runtime` lets browser feature specs exercise production slot, store, rendering, update, and disposal behavior in jsdom without reimplementing the UI runtime. Test authors can publish typed Session, Workspace, projection, and Conversation fixtures, query slot-local DOM roots, and script Remote replies or failures. Missing services, unstubbed session behavior, and unexpected file uploads fail at the call site, while disposal is idempotent. Use it only from in-repository browser-oriented Vitest suites through `devDependencies`; it is not a product plugin or general Node test harness. +`SlotTestRuntime.create()` lets Vitest suites drive production slots, stores, typed Session and Workspace fixtures, and local DOM assertions in jsdom. For plugin activation, reload, reconnect, and cleanup tests, `createClientTest` starts the web profile's bundle roster with endpoint-named Remote mocks, without a business Host. Missing services and unstubbed calls fail explicitly. The whole-client fixture owns startup and disposal; the local runtime provides idempotent disposal. Use this package through `devDependencies` for client tests; it is not a product plugin. ## Table of Contents @@ -64,9 +64,34 @@ remote.goals.create.mockResolvedValue({ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ``` +### Whole-client tier + +The slot tier above mounts one feature against doubles. The whole-client tier boots the real assembly: `TestClient.start(plan, mock, options)` installs `{ rpc: mock.rpc }` as `globalThis.__DSH_TRANSPORT__`, imports every roster row's `/client` module in-process (or takes the plan's `provide` replacement), synthesizes the boot graph with `graphFromRoster` and hands the loaded modules to the production module system, boots through the production `bootClient`, optionally mounts `uiRenderer`, and waits for `ctx.connection.state === 'connected'`. It lives behind a deep import so slot-tier specs never load it: + +```text +// @vitest-environment jsdom +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { ok } from '@deepseek-ai/dsh-remote-mock' + +const test = createClientTest({ roster: webApp }, { mount: true }) +test('registers into the sidebar', async ({ remote, start }) => { + remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] })) + const client = await start() + expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1) +}) +``` + +`createClientTest` uses native Vitest fixtures: each test gets a fresh `mock` loaded with `remoteDefaultResponses`, a `remote` proxy equal to `mock.remote`, and a `start()` that boots only after the test configures its responses. Repeated starts share one promise; await it to observe startup errors. Fixture teardown waits for startup, disposes the client even after a failed assertion, checks missing responses, and refuses a saved `start` after the test. Use `TestClient.start` directly for independently owned clients. These fixtures isolate their own state, not page globals such as `location`. + +The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every namespace in both tiers. An assembled test uses the `remote` fixture; a local `TestRemote` can receive `{ settings: mock.remote.settings }`. Configure returned data and read native `.mock.calls` directly. A mutation response does not update later describe answers automatically: change `remote.settings.describe.mockResolvedValue(...)` explicitly when the scenario publishes new data. The proxy documentation owns unbuilt typing and the required build-backed local typecheck. + +### Roster and startup behavior + +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles)` does the same for any bundle list. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry the way client-hmr does (registry-first teardown, then `entry.refresh()`), under the worker's boot turn with this client's carrier installed so a rebuilt `connection` row reads its own mock; `unload(name)` removes it and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` (layout components observe size at mount), so `start` installs inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a product requirement. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. + ### When to use it -Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is browser-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. +Use the bench for feature suites that exercise slots, stores, rendering, and disposal under a real runtime — the production `SlotRegistry`, renderer, and provide-bundle materialization are mounted, never reimplemented. It is client-side test infrastructure: it never reaches a model request, and feature packages depend on it in `devDependencies` only. ### What can go wrong @@ -99,6 +124,13 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/settings-scope.ts`](src/settings-scope.ts) | `stubSettingsScope` with test-driven publications and a write spy | +| [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`, `ClientRoster` (`of`/`closure`/`pick`/`without`), the `AssemblyPlan` it annotates, and `graphFromRoster` | +| [`src/assembly/modules.ts`](src/assembly/modules.ts) | Source `/client` imports and replacements, registered through the production module facade's pending factory queue | +| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`: transport install, jsdom shims, `bootClient`, mount, readiness wait, `reload`/`unload`/`dispose` | +| [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | Test-scoped `mock` and lazy `start` fixtures | +| [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`: default responses of the Remote endpoints the roster calls at boot | +| [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | Contract-free `remote.` proxies over the Connection: `remoteNamespacesOf`, `remoteProxiesPlugin` | +| [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` and `webApp`: the browser roster read from bundle patch files with the include plugin's own schema and patch application | | — | No runtime invariant companion is published; this test-support package owns no production event stream or mutable data — it assembles the runtime SlotRegistry and renderer (whose packages own their invariants) around test doubles; its own behavior is exercised by its package tests. | ### Lifecycle @@ -138,7 +170,10 @@ None; this package neither assembles nor sends a provider request. These limits define how the bench is consumed. They are current package constraints, not a task backlog. -- **Vitest and jsdom only** — every consumer is an in-repository browser-oriented Vitest suite. The package is not a product plugin or a general Node test harness. +- **The whole-client tier does not run the generated Remote clients** — `remote.` proxies forward positional arguments without the generated zod validation, wire-name mapping, or scoped-identity injection; mock handlers receive those arguments directly, and the generated clients stay covered by the built-artifact e2e lanes. +- **Proxied calls bypass the Gateway client's `invoke` and `invokeStream`** — no `$mount` lifecycle check runs, stream failures are not re-marked through `normalizeConnectionStream`, and a unary rejection is folded by the proxy itself with the Gateway client's exported `carrierFailure` and `cancelledFailure`. `ctx.remote.$stream`, `$on`, and `$host` are the real Gateway client's. +- **An undeclared endpoint is dispatched as a unary call** — the proxy learns each endpoint's mode from the mock's registrations; a stream endpoint the spec neither scripts nor declares (`RemoteTable.streams`, `mock.stream(endpoint)`) is logged as a `unary` miss and product code receives a folded result rather than a failing stream. `remoteDefaultResponses` declares the roster's later-opened streams; `dispose()` fails the test either way. +- **The package's client compile program adds the `node` ambient types** so the roster reader can use `node:fs`; the slot-tier sources compile against them as well. - **Session, Conversation, and Chat fixtures stay separate** — `sessionSnapshot` contains only Session Controller state, `conversationSnapshot` contains target-neutral Conversation state, and `chatSnapshot` contains Chat target state. Assembly tests provide Session event entries instead of adding Conversation or Chat fields to `SessionSnapshot`. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index ce78a0ad62..d9c6888f17 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-test-runtime` 让浏览器功能测试在 jsdom 中检验生产 slot、存储、渲染、更新与销毁行为,而无需重实现 UI 运行时。测试作者可以发布带类型的 Session、Workspace、projection 与 Conversation fixture(测试前置数据),查询 slot 局部 DOM 根,并脚本化 Remote 应答或失败。缺失服务、未打桩的会话行为与意外文件上传都会在调用点失败,销毁则保持幂等。仅限仓内、面向浏览器的 Vitest 套件通过 `devDependencies` 使用本包;它不是产品插件或通用 Node 测试 harness。 +`SlotTestRuntime.create()` 让 Vitest 套件在 jsdom 中驱动生产 slot、store、带类型的 Session 与 Workspace fixture,并对局部 DOM 断言。面向插件激活、重载、重连与清理的测试,`createClientTest` 使用具名端点 Remote mock 启动 web profile 的 bundle roster,无需业务 Host。缺失服务与未打桩调用会明确失败。整机 fixture 拥有启动和销毁,局部 runtime 提供幂等销毁。通过 `devDependencies` 将本包用于客户端测试;它不是产品插件。 ## 目录 @@ -64,9 +64,34 @@ remote.goals.create.mockResolvedValue({ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ``` +### 整体档 + +上面的 slot 档把一个功能挂在替身上。整体档起真实装配:`TestClient.start(plan, mock, options)` 把 `{ rpc: mock.rpc }` 装到 `globalThis.__DSH_TRANSPORT__`,进程内 import 每个 roster 行的 `/client` 模块(或取计划里的 `provide` 替换),用 `graphFromRoster` 合成启动图并把已加载模块交给生产模块系统,经生产 `bootClient` 启动,按需挂载 `uiRenderer`,再等 `ctx.connection.state === 'connected'`。它藏在深 import 后面,slot 档测试永不加载它: + +```text +// @vitest-environment jsdom +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' +import { ok } from '@deepseek-ai/dsh-remote-mock' + +const test = createClientTest({ roster: webApp }, { mount: true }) +test('registers into the sidebar', async ({ remote, start }) => { + remote.settings.describe.mockResolvedValue(ok({ writable: true, hasDocument: false, namespaces: [] })) + const client = await start() + expect(client.ctx.slots.entries('sidebar.settings')).toHaveLength(1) +}) +``` + +`createClientTest` 使用原生 Vitest fixture:每个测试获得已加载 `remoteDefaultResponses` 的新 `mock`、等同于 `mock.remote` 的 `remote` Proxy,以及配置应答后才起机的 `start()`。重复启动共用一个 Promise,调用方必须 await 它来观察启动错误。fixture 收尾等待启动,即使断言失败也销毁客户端、检查漏配,并拒绝测试结束后保存的 `start` 调用。需要分别拥有多个客户端时直接用 `TestClient.start`。这些 fixture 隔离自己的状态,不隔离 `location` 等页面全局。 + +两档测试的所有命名空间都使用[通用 Remote Proxy](../remote-mock/README.zh.md#remote-proxy)。装配测试使用 `remote` fixture;局部 `TestRemote` 可以接收 `{ settings: mock.remote.settings }`。直接配置返回数据,并读取原生 `.mock.calls`。mutation 应答不会自动更新后续 describe 应答:场景发布新数据时,显式修改 `remote.settings.describe.mockResolvedValue(...)`。Proxy 文档拥有无构建类型说明和必需的构建后本地类型检查规则。 + +### Roster 与启动行为 + +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles)` 对任意 bundle 列表做同样的事。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 按 client-hmr 的方式重建一个 Loader entry(先拆 registry,再 `entry.refresh()`),并在 worker 的启动轮次内装上本客户端的载体,重建的 `connection` 行因此读到自己的 mock;`unload(name)` 移除它并等待插件清理完成;`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver`(布局组件挂载时观察尺寸),所以 `start` 对缺失的全局装惰性桩、`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是产品需求。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 + ### 何时使用 -当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是浏览器侧测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 +当功能套件要在真实运行时下检验 slot、存储、渲染与销毁时使用本测试台——生产 `SlotRegistry`、渲染器与 provide bundle 物化都会被挂载,绝不重实现。它是客户端测试基础设施:永远不触及模型请求,功能包仅以 `devDependencies` 依赖之。 ### 可能出什么问题 @@ -99,7 +124,14 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/settings-scope.ts`](src/settings-scope.ts) | 带测试驱动发布与写入 spy 的 `stubSettingsScope` | -| — | 不发布运行时不变式伴生入口;本测试支持包不拥有任何生产事件流或可变数据;它围绕测试替身组装运行时 SlotRegistry 与渲染器,其所属包拥有各自的不变式;本包自身行为由本包测试检验。 | +| [`src/assembly/roster.ts`](src/assembly/roster.ts) | `ClientRosterRow`、`ClientRoster`(`of`/`closure`/`pick`/`without`)、它所标注的 `AssemblyPlan`,以及 `graphFromRoster` | +| [`src/assembly/modules.ts`](src/assembly/modules.ts) | 源码 `/client` 导入及替换,通过生产模块 facade 的待注册工厂队列登记 | +| [`src/assembly/test-client.ts`](src/assembly/test-client.ts) | `TestClient`:装传输、jsdom 桩、`bootClient`、挂载、等就绪、`reload`/`unload`/`dispose` | +| [`src/assembly/vitest.ts`](src/assembly/vitest.ts) | 测试级 `mock` 与懒启动 `start` fixture | +| [`src/assembly/remote-default-responses.ts`](src/assembly/remote-default-responses.ts) | `remoteDefaultResponses`:roster 启动期 Remote 端点的默认响应 | +| [`src/assembly/remote-proxies.ts`](src/assembly/remote-proxies.ts) | 经 Connection 的无契约 `remote.` 代理:`remoteNamespacesOf`、`remoteProxiesPlugin` | +| [`src/assembly/bundle-roster.ts`](src/assembly/bundle-roster.ts) | `bundleRoster` 与 `webApp`:用 include 插件自己的 schema 与补丁应用从 bundle 补丁文件读出浏览器 roster | +| — | 不发布运行时不变式伴生入口;本测试支持包不拥有生产事件流或可变数据,而是围绕测试替身组装生产 SlotRegistry 与渲染器。所挂载的生产包拥有各自的不变式,本包行为由本包测试检验。 | ### 生命周期 @@ -138,7 +170,10 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') 这些限制说明本测试台如何被消费。它们是当前包约束,不是任务积压。 -- **仅限 Vitest 与 jsdom**——所有消费方都是仓内、面向浏览器的 Vitest 套件。本包不是产品插件,也不是通用 Node 测试 harness。 +- **整体档不运行生成的 Remote 客户端**——`remote.` 代理转发位置参数,不经过生成的 zod 校验、wire 名映射或 scoped 身份注入;mock handler 直接接收这些参数,生成客户端仍由 built-artifact e2e 车道覆盖。 +- **代理调用绕过 Gateway 客户端的 `invoke` 与 `invokeStream`**——不做 `$mount` 生命周期检查,流失败不经 `normalizeConnectionStream` 重新标记,一元拒绝由代理自己用 Gateway 客户端导出的 `carrierFailure` 与 `cancelledFailure` 折叠。`ctx.remote.$stream`、`$on`、`$host` 是真 Gateway 客户端的。 +- **未声明的端点按一元调用发出**——代理从 mock 的登记学到每个端点的模式;spec 既没给脚本也没声明(`RemoteTable.streams`、`mock.stream(endpoint)`)的流端点记为 `unary` 漏配,产品代码收到的是折叠结果而不是失败的流。`remoteDefaultResponses` 声明了 roster 启动后才打开的流;无论哪种,`dispose()` 都会让测试失败。 +- **本包的 client 编译程序加了 `node` 环境类型**,好让 roster 读取器使用 `node:fs`;slot 档的源码也在这些类型下编译。 - **Session、Conversation 与 Chat fixture 保持分离**——`sessionSnapshot` 只包含 Session 控制器状态,`conversationSnapshot` 包含与目标无关的 Conversation 状态,`chatSnapshot` 包含 Chat 目标状态。组装测试提供 Session 事件条目,而不是向 `SessionSnapshot` 添加 Conversation 或 Chat 字段。 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 44cd37bc91..944536df62 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", - "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.5-rc.1", + "description": "Browser test runtimes: a jsdom slot bench with test-owned Session and Workspace doubles, and a whole-client tier that boots the web roster through the production bootClient over an endpoint-named Remote mock", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, @@ -25,13 +25,20 @@ "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", + "js-yaml": "^4.2.0", "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -39,18 +46,28 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-base": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-hmr": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -59,12 +76,16 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-web-app": "workspace:^", + "@types/js-yaml": "^4.0.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts new file mode 100644 index 0000000000..14284556f5 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -0,0 +1,138 @@ +/** + * The browser roster of a `dsh --profile`, read from its bundle patch files + * the way the launcher composes them: each bundle's `dsh.bundle.patch` list is + * parsed with the include plugin's YAML dialect (`entryListSchema`) and + * composed by its `applyEntryPatches`; every enabled row whose package + * declares `dsh.client.platform === 'web'` becomes a roster row carrying that + * declaration's `inject` and `immediately`; rows nested in Loader groups count + * like the Loader counts them, a disabled group disabling every row beneath + * it. A patch that matches nothing + * throws here where the launcher warns. Nothing is copied from the bundles: a + * bundle change is visible at the next import. Node only — the + * whole-client tier runs under vitest, and this is the one place it reads the + * repository. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/bundle-roster + */ +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { exactPackageSpecifier, parseDshClient } from '@deepseek-ai/dsh-client-modules/client' +import * as yaml from 'js-yaml' +import { ClientRoster, type ClientRosterRow } from './roster.ts' + +/** The `web` profile's bundle layers, in the order `dsh --profile web` applies them (app-boot `PROFILE_TEMPLATES.web`). */ +export const WEB_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] + +interface PackageManifest { + name?: unknown + dsh?: { bundle?: { patch?: unknown }; client?: unknown } +} + +/** One bundle: where its package.json is (plugin names resolve from there) and its parsed patch list. */ +interface BundleLayer { + readonly manifestPath: string + readonly patches: PatchOptions[] +} + +/** + * Compose the browser roster of `bundles`, applied in order. + * @param bundles - bundle package names in application order. + * @param anchor - file whose package resolution locates the bundles; default this package. + * @returns the roster in composition order, one row per package. + * @throws {Error} when a bundle, its patch file, or an enabled row's package does not resolve, when the patch list + * is not a list or does not apply as written, or when a browser row's `disabled` is a `!!js` expression. + */ +export function bundleRoster(bundles: readonly string[], anchor: string = fileURLToPath(import.meta.url)): ClientRoster { + const layers = bundles.map(name => readLayer(name, anchor)) + const entries = applyEntryPatches([], layers.flatMap(layer => layer.patches), (message: string, ...args: unknown[]) => { + throw new Error(`client-test-runtime: bundle patch ${describe(message, args)}`) + }) + const anchors = layers.map(layer => layer.manifestPath) + const rows: ClientRosterRow[] = [] + const seen = new Set() + for (const { entry, disabled } of flattenGroups(entries)) { + const name = exactPackageSpecifier(entry.name) + if (name === undefined || disabled === true || seen.has(name)) continue + seen.add(name) + const manifestPath = locateManifest(anchors, name) + if (manifestPath === undefined) { + throw new Error(`client-test-runtime: cannot resolve plugin package ${name} from ${bundles.join(', ')}`) + } + const manifest = readManifest(manifestPath) + if (manifest.name !== name) { + throw new Error(`client-test-runtime: ${manifestPath} names ${JSON.stringify(manifest.name)}, expected ${name}`) + } + const declaration = parseDshClient(name, manifest.dsh?.client) + if (declaration === undefined || declaration.platform !== 'web') continue + if (disabled !== undefined && disabled !== null && typeof disabled !== 'boolean') { + throw new Error(`client-test-runtime: browser row ${name} has a \`disabled\` value this reader cannot evaluate (a !!js expression)`) + } + rows.push({ name, inject: declaration.inject ?? [], immediately: declaration.immediately === true }) + } + return ClientRoster.of(rows) +} + +function readLayer(bundle: string, anchor: string): BundleLayer { + const manifestPath = locateManifest([anchor], bundle) + if (manifestPath === undefined) throw new Error(`client-test-runtime: cannot resolve bundle ${bundle} from ${anchor}`) + const patch = readManifest(manifestPath).dsh?.bundle?.patch + if (typeof patch !== 'string') throw new Error(`client-test-runtime: bundle ${bundle} declares no dsh.bundle.patch in ${manifestPath}`) + const file = join(dirname(manifestPath), patch) + const parsed: unknown = yaml.load(readFileSync(file, 'utf8'), { schema: entryListSchema }) + if (!Array.isArray(parsed)) throw new Error(`client-test-runtime: ${file} must be a top-level list of patches`) + return { manifestPath, patches: parsed as PatchOptions[] } +} + +/** One Loader row with the `disabled` value that governs it: its own, or the nearest enclosing group's when that is set. */ +interface FlatEntry { + readonly entry: EntryOptions + readonly disabled: unknown +} + +/** + * Rows in Loader order with groups descended, as the Loader loads them: a group is never a plugin itself, and a group's + * `disabled` disables every row beneath it. + * @param entries - composed entries, possibly nested. + * @param inherited - the enclosing group's `disabled` when set. + * @returns the plugin rows. + */ +function flattenGroups(entries: readonly EntryOptions[], inherited?: unknown): FlatEntry[] { + const rows: FlatEntry[] = [] + for (const entry of entries) { + const own = (entry as { disabled?: unknown }).disabled + const disabled = inherited !== undefined && inherited !== null && inherited !== false ? inherited : own + if (entry.group === true && Array.isArray(entry.config)) { + rows.push(...flattenGroups(entry.config as EntryOptions[], disabled)) + continue + } + rows.push({ entry, disabled }) + } + return rows +} + +function readManifest(path: string): PackageManifest { + return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest +} + +/** Locate `/package.json` on the resolution paths of any anchor, without requiring a `./package.json` export. */ +function locateManifest(anchors: readonly string[], name: string): string | undefined { + for (const anchor of anchors) { + for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) { + const candidate = join(searchPath, name, 'package.json') + if (existsSync(candidate)) return candidate + } + } + return undefined +} + +/** The include plugin's `%C` placeholders, filled the way the launcher prints them. */ +function describe(message: string, args: readonly unknown[]): string { + let index = 0 + return message.replace(/%C/g, () => JSON.stringify(args[index++])) +} + +/** The `web` profile's browser roster, composed from its bundles at import. */ +export const webApp: ClientRoster = bundleRoster(WEB_PROFILE_BUNDLES) diff --git a/packages/test-support/client-runtime/src/assembly/index.ts b/packages/test-support/client-runtime/src/assembly/index.ts new file mode 100644 index 0000000000..5223e691b3 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/index.ts @@ -0,0 +1,14 @@ +/** + * Whole-client tier entry (deep import only: + * `@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts`). Kept out of + * the package root so slot-tier specs do not load the assembly machinery. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly + */ +export { ClientRoster } from './roster.ts' +export type { AssemblyPlan, ClientPluginModule, ClientRosterRow } from './roster.ts' +export { TestClient } from './test-client.ts' +export type { TestClientOptions } from './test-client.ts' +export { remoteDefaultResponses } from './remote-default-responses.ts' +export { bundleRoster, webApp } from './bundle-roster.ts' +export { createClientTest } from './vitest.ts' +export type { ClientTestFixtures } from './vitest.ts' diff --git a/packages/test-support/client-runtime/src/assembly/modules.ts b/packages/test-support/client-runtime/src/assembly/modules.ts new file mode 100644 index 0000000000..60a2fdbfdf --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/modules.ts @@ -0,0 +1,80 @@ +/** + * In-process module arrival: import every roster row's `/client` module (or + * the plan's replacement) and register each as a pre-arrived factory on a + * production `ClientModuleSystem`, so neither the Loader's `internal.import` + * nor a stage-one `prefetch` ever fetches a bundle. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/modules + */ +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import { createClientModuleSystem } from '@deepseek-ai/dsh-client-modules/client' +import type { ClientModuleLoader, ClientModuleLoaderTarget, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import type { AssemblyPlan, ClientPluginModule } from './roster.ts' + +/** The bootstrap row: always this process's static namespace, never a dynamic import or a `provide` replacement. */ +export const MODULES_PACKAGE = '@deepseek-ai/dsh-client-modules' + +/** + * Resolve each roster row to its plugin module: `plan.provide[name]` when + * present, otherwise a `/client` import resolved by the repository's tsconfig + * path aliases under Vitest. The bootstrap row is the + * statically imported `@deepseek-ai/dsh-client-modules/client` namespace. + * @param plan - validated plan. + * @returns package name → module, in roster order. + * @throws {Error} when an import fails (the package name prefixes the original message) or the bootstrap row is provided. + */ +export async function loadPluginModules(plan: AssemblyPlan): Promise> { + const modules = new Map() + for (const { name } of plan.roster.rows) { + const provided = plan.provide?.[name] + if (name === MODULES_PACKAGE) { + if (provided !== undefined) { + throw new Error(`client-test-runtime: ${MODULES_PACKAGE} is the bootstrap module and cannot be provided`) + } + modules.set(name, modulesClient) + continue + } + modules.set(name, provided ?? await importClient(name)) + } + return modules +} + +async function importClient(name: string): Promise { + let namespace: unknown + try { + namespace = await import(/* @vite-ignore */ `${name}/client`) + } catch (error) { + throw new Error(`client-test-runtime: cannot import ${name}/client: ${String(error)}`, { cause: error }) + } + return namespace as ClientPluginModule +} + +/** + * Build the production module system over queued factories returning the + * loaded namespaces. The bootstrap row uses `bootstrapModule`; `staticModules` + * is empty because namespaces already hold their own imports. Missing factories + * reject through `loadBundle` without fetching. + * @param graph - raw boot graph from `graphFromRoster`; `createClientModuleSystem` parses it. + * @param modules - loaded plugin modules keyed by package name. + * @returns module system to install as `loader.internal`; its `manifest` is the parsed graph. + */ +export function createInProcessModules( + graph: WebBootGraph, + modules: ReadonlyMap, +): ClientModuleLoader { + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue: [], + /* v8 ignore next -- construction replaces this sink before draining the prefilled queue. */ + load: () => { throw new Error('client-test-runtime: module facade is not initialized') }, + create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: modulesClient }, options), + } + for (const [id, namespace] of modules) { + if (id === MODULES_PACKAGE) continue + target.pendingQueue.push({ id, factory: () => namespace as unknown as Record }) + } + return target.create({ + boot: graph, + staticModules: {}, + loadBundle: url => Promise.reject(new Error(`client-test-runtime: in-process modules never load bundles (${url})`)), + }) +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts new file mode 100644 index 0000000000..2c0dfddbd1 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -0,0 +1,45 @@ +/** + * Default responses for every Remote endpoint the web assembly calls while + * booting and rendering with no sessions, no workspaces, and default settings. + * The comment above each row names the plugin that calls it; endpoints boot + * never touches stay absent so a new call fails loud. `$events` is built into + * `RemoteMock`. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-default-responses + */ +import { ok, openStream, type RemoteTable } from '@deepseek-ai/dsh-remote-mock' + +/** Default responses of the boot-time Remote endpoints; a spec loads it first and layers its own table on top. */ +export const remoteDefaultResponses: RemoteTable = { + unary: { + // api-session-controller `sessions.handleConnected()` on `connection/reset`. + 'session/list': ok({ items: [] }), + // ui-settings `mirror.ensure()` at apply and again on `connection/reset`. + 'settings/describe': ok({ writable: true, hasDocument: false, namespaces: [] }), + // ui-model-selection `ModelDirectoryResolver` constructor. + 'session/modelCatalog': ok({ + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: [], + groups: [], + failures: [], + }), + // ui-agent-preset hero chip and header label on first mount. + 'agentPresets/list': ok({ presets: [], authorable: false }), + // cordis-client-runner `ClientCordisInspectRegistry.sync` at apply and on `connection/reset`. + 'dynamicCordisRunner/syncInspectManifest': ok(null), + // ui-cordis inventory at apply and on `connection/reset`. + 'dynamicCordisRunner/inventory': ok([]), + // ui-settings-plugins web-search card `readCredential()` when the settings mirror first publishes. + 'credentials/describe': ok({}), + }, + // Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss. + streams: [ + // api-session-controller `SessionEventStream.follow` when a Session opens. + 'session/follow', + ], + stream: { + // api-session-controller client `apply`: the control stream's opening baseline, then open. + 'session/control': openStream([{ type: 'baseline', value: { queues: {}, jobs: {}, projections: {} } }]), + // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. + 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [] } }]), + }, +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-proxies.ts b/packages/test-support/client-runtime/src/assembly/remote-proxies.ts new file mode 100644 index 0000000000..d1a3b09142 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/remote-proxies.ts @@ -0,0 +1,87 @@ +/** + * `remote.` services for the whole-client tier, without the generated + * Remote clients. Cordis resolves `ctx.remote.` to whichever service is + * registered under `remote.` (vendor cordis `utils.ts`, traceable get), so + * the carrier provides one Proxy per namespace: `remote..(...args)` + * calls the endpoint `/` over the roster's own Connection with the + * positional `args`, as a stream when the mock registered a stream script for + * it and as a unary call otherwise. A unary answer is returned unchanged and a + * unary rejection is folded the way the generated client folds a carrier + * throw (`gateway/internal`, or `gateway/cancelled` once the caller's signal + * aborted), so product code that never awaits a rejection sees none; stream + * items and failures pass through as the stream yields them. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/remote-proxies + */ +import type { Context } from '@deepseek-ai/cordis' +import { cancelledFailure, carrierFailure } from '@deepseek-ai/dsh-api-gateway/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import type { ClientPluginModule } from './roster.ts' + +/** The assembly row the proxies stand in for; its generated clients exist only in built `lib/`. */ +export const REMOTES_PACKAGE = '@deepseek-ai/dsh-api-remotes' + +const PREFIX = 'remote.' + +/** + * Namespaces to provide: every `remote.` a roster module injects, plus the + * namespace of every endpoint the mock has a rule for. + * @param modules - loaded roster modules. + * @param mock - the spec's mock. + * @returns sorted namespace names. + */ +export function remoteNamespacesOf(modules: Iterable, mock: RemoteMock): readonly string[] { + const names = new Set() + for (const module of modules) { + for (const service of injectNames(module.inject)) if (service.startsWith(PREFIX)) names.add(service.slice(PREFIX.length)) + } + for (const endpoint of mock.endpoints()) { + const slash = endpoint.indexOf('/') + if (slash > 0 && !endpoint.startsWith('$')) names.add(endpoint.slice(0, slash)) + } + return [...names].sort() +} + +function injectNames(inject: ClientPluginModule['inject']): readonly string[] { + if (inject === undefined) return [] + if (Array.isArray(inject)) return inject as readonly string[] + return Object.keys(inject) +} + +/** + * Plugin providing the namespace proxies; `TestClient.start` mounts it before the Loader rows. + * @param namespaces - namespaces to provide. + * @param mock - the spec's mock, asked for each endpoint's mode. + * @returns the plugin. + */ +export function remoteProxiesPlugin(namespaces: readonly string[], mock: RemoteMock): ClientPluginModule { + return { + inject: ['connection'], + apply(ctx: Context) { + const connection = ctx.get('connection') as ConnectionHandle + for (const namespace of namespaces) ctx.provide(`${PREFIX}${namespace}`, namespaceProxy(namespace, connection, mock)) + }, + } +} + +function namespaceProxy(namespace: string, connection: ConnectionHandle, mock: RemoteMock): object { + return new Proxy(Object.create(null) as Record, { + get: (_target, property) => { + // No `then`: awaiting the namespace object itself must not call a method. + if (typeof property !== 'string' || property === 'then') return undefined + return (...values: readonly unknown[]): unknown => { + const endpoint = `${namespace}/${property}` + const args = [...values] + const signal = args.at(-1) instanceof AbortSignal ? (args.pop() as AbortSignal) : undefined + if (mock.modeOf(endpoint) === 'stream') { + const open = connection.rpc.open + /* v8 ignore next -- the mock transport always supplies openStream, so the Connection carrier exposes open. */ + if (open === undefined) throw new Error(`client-test-runtime: ${endpoint} is a stream but the carrier has no in-process opener`) + return open('/api', endpoint, { args }, signal ?? new AbortController().signal) + } + return connection.rpc.call('/api', endpoint, { args }, signal).catch((error: unknown) => + (signal?.aborted === true ? cancelledFailure(endpoint, error) : carrierFailure(endpoint, error))) + } + }, + }) +} diff --git a/packages/test-support/client-runtime/src/assembly/roster.ts b/packages/test-support/client-runtime/src/assembly/roster.ts new file mode 100644 index 0000000000..82aa14df93 --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/roster.ts @@ -0,0 +1,163 @@ +/** + * Client roster: the ordered package-name rows a whole-client test boots, and + * the plan that annotates one with the rows the test provides itself. `webApp` + * and `bundleRoster` (`./bundle-roster.ts`) read rosters from the bundle patch + * files; a spec may also build one inline with {@link ClientRoster.of}. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/roster + */ +import type { Context } from '@deepseek-ai/cordis' +import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import { PLATFORM_MODULES } from '@deepseek-ai/dsh-client-web/src/platform.ts' + +/** One browser plugin row as `dsh.client` declares it, keyed by package name. */ +export interface ClientRosterRow { + /** Package name (== manifest entry id == Loader entry name). */ + readonly name: string + /** Package-name dependency edges from `dsh.client.inject` ([] when absent). */ + readonly inject: readonly string[] + /** Stage-one prefetch mark from `dsh.client.immediately` (false when absent). */ + readonly immediately: boolean +} + +/** Revision stamped on every synthesized row and batch; nothing is fetched by it. */ +const LOCAL_REV = 'local' + +/** + * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch + * holding every row, `rev: 'local'`, placeholder `/plugins//client.js` + * URLs, since every module is seeded in process and never fetched. Validation + * stays with the production `parseBootManifest` inside the module system: + * duplicate names and an empty roster are rejected there, not here. + * @param rows - roster rows in composition order. + * @returns the unparsed graph, as `createClientModuleSystem` consumes it. + */ +export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph { + const entries: WebBootEntry[] = rows.map(row => ({ + id: row.name, + url: `/plugins/${row.name}/client.js`, + rev: LOCAL_REV, + ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}), + ...(row.immediately ? { immediately: true } : {}), + })) + return { + rev: LOCAL_REV, + entries, + batches: [{ + phase: 'application', + url: '/plugins/local.js', + rev: LOCAL_REV, + entries: entries.map(entry => entry.id), + }], + } +} + +/** Module names the shell seeds before any row loads; an inject edge to one of them is satisfied without a row. */ +const PLATFORM_SEED: ReadonlySet = new Set(PLATFORM_MODULES) + +/** Immutable, name-addressable roster. */ +export class ClientRoster { + /** + * Build a roster from rows; duplicate names throw. + * @param rows - roster rows in composition order. + * @returns roster. + */ + static of(rows: readonly ClientRosterRow[]): ClientRoster { + const seen = new Set() + const duplicates = new Set() + for (const { name } of rows) { + if (seen.has(name)) duplicates.add(name) + seen.add(name) + } + if (duplicates.size > 0) { + throw new Error(`client-test-runtime: duplicate roster rows: ${[...duplicates].join(', ')}`) + } + return new ClientRoster(Object.freeze([...rows])) + } + + private constructor(readonly rows: readonly ClientRosterRow[]) {} + + /** + * Keep only `names`, preserving roster order; an unknown name throws with the roster listed. + * @param names - package names to keep. + * @returns sub-roster. + */ + pick(names: readonly string[]): ClientRoster { + const keep = this.known(names, 'pick') + return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name)))) + } + + /** + * The named rows plus every row they inject, transitively, in roster order: the rows a spec needs to boot the + * named plugins as the bundle composes them. The shell's platform modules (`PLATFORM_MODULES`, seeded statically + * rather than loaded as rows) end the walk. An unknown name throws with the roster listed; a row injecting any + * other package outside the roster throws, since the bundle itself would not boot. + * @param names - package names whose dependency cone to keep. + * @returns sub-roster. + */ + closure(names: readonly string[]): ClientRoster { + this.known(names, 'closure') + const byName = new Map(this.rows.map(row => [row.name, row])) + const keep = new Set() + const visit = (name: string, from: string | undefined): void => { + if (keep.has(name) || PLATFORM_SEED.has(name)) return + const row = byName.get(name) + if (row === undefined) { + throw new Error(`client-test-runtime: ${String(from)} injects ${name}, which is outside the roster`) + } + keep.add(name) + for (const dependency of row.inject) visit(dependency, name) + } + for (const name of names) visit(name, undefined) + return new ClientRoster(Object.freeze(this.rows.filter(row => keep.has(row.name)))) + } + + /** + * Drop `names`; an unknown name throws with the roster listed. + * @param names - package names to drop. + * @returns sub-roster. + */ + without(names: readonly string[]): ClientRoster { + const drop = this.known(names, 'without') + return new ClientRoster(Object.freeze(this.rows.filter(row => !drop.has(row.name)))) + } + + private known(names: readonly string[], operation: string): ReadonlySet { + const rostered = this.rows.map(row => row.name) + const unknown = names.filter(name => !rostered.includes(name)) + if (unknown.length > 0) { + throw new Error( + `client-test-runtime: ${operation}() names outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`, + ) + } + return new Set(names) + } +} + +/** The module face the Loader materializes for one client plugin row. */ +export interface ClientPluginModule { + apply(ctx: Context, config?: unknown): unknown + readonly inject?: readonly string[] | Readonly> + readonly Config?: unknown +} + +/** What to boot and what the test supplies itself. */ +export interface AssemblyPlan { + readonly roster: ClientRoster + /** Row replacements by package name (the test's own implementation of that row). Names outside the roster throw. */ + readonly provide?: Readonly> +} + +/** + * Validate a plan against its roster. + * @param plan - plan to check. + * @throws {Error} naming any `provide` key outside the roster. + */ +export function assertPlan(plan: AssemblyPlan): void { + const rostered = plan.roster.rows.map(row => row.name) + const unknown = Object.keys(plan.provide ?? {}).filter(name => !rostered.includes(name)) + if (unknown.length > 0) { + throw new Error( + `client-test-runtime: provide names rows outside the roster: ${unknown.join(', ')}; roster: ${rostered.join(', ')}`, + ) + } +} diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts new file mode 100644 index 0000000000..fd99de38fd --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -0,0 +1,364 @@ +/** + * Whole-client test carrier: boots an {@link AssemblyPlan} through the + * production `bootClient` over an in-process module table, with a + * `RemoteMock` installed as the Connection carrier through `__DSH_TRANSPORT__.rpc`. + * @module @deepseek-ai/dsh-client-test-runtime/src/assembly/test-client + */ +import { Context, type Plugin } from '@deepseek-ai/cordis' +import type { Entry } from '@deepseek-ai/cordis-plugin-loader' +import { tearDownEntryFiber } from '@deepseek-ai/dsh-client-hmr/client' +import type { ClientTransportHooks, ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { bootClient } from '@deepseek-ai/dsh-client-web/src/boot-client.ts' +import { mountClient } from '@deepseek-ai/dsh-client-web/src/mount.ts' +import type { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { act } from '@testing-library/react' +import { createInProcessModules, loadPluginModules } from './modules.ts' +import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts' +import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts' + +/** Carrier options. */ +export interface TestClientOptions { + /** + * Mount `uiRenderer` into an element (a fresh `document.body` child when `true`); requires jsdom and a roster + * that provides `uiRenderer`. Default false. + */ + readonly mount?: boolean | HTMLElement + /** Wait for `ctx.connection.state === 'connected'` before returning. Default true. */ + readonly awaitConnected?: boolean + /** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */ + readonly connectTimeoutMs?: number +} + +/** Page global the connection plugin reads its carrier from. */ +interface TransportGlobal { + __DSH_TRANSPORT__?: ClientTransportHooks +} + +const transportGlobal = globalThis as TransportGlobal + +/** + * Process globals every live client in this worker shares: the transport the + * `connection` plugin reads at apply, and the jsdom shims. The first holder + * installs them and remembers what was there; the last release removes the + * shims and restores the transport. A holder installs its transport for its + * own boot and for each rebuild it runs, both under the worker's boot turn, so + * overlapping clients and out-of-order disposal neither clobber a booting or + * rebuilding client nor leak into later tests. + */ +class SharedGlobals { + private holders = 0 + private previousTransport: ClientTransportHooks | undefined + private removeShims: (() => void) | undefined + + /** + * Point the transport global at `transport` for the boot or rebuild about to run; the holder must already hold. + * @param transport - carrier the next `connection` apply reads. + */ + install(transport: ClientTransportHooks): void { + transportGlobal.__DSH_TRANSPORT__ = transport + } + + /** + * Hold the globals with `transport` installed. + * @param transport - carrier the next boot reads. + * @returns the release for this holder. + */ + acquire(transport: ClientTransportHooks): () => void { + if (this.holders === 0) { + this.previousTransport = transportGlobal.__DSH_TRANSPORT__ + this.removeShims = installJsdomShims() + } + this.holders += 1 + this.install(transport) + return () => { + this.holders -= 1 + if (this.holders > 0) return + this.removeShims?.() + this.removeShims = undefined + if (this.previousTransport === undefined) delete transportGlobal.__DSH_TRANSPORT__ + else transportGlobal.__DSH_TRANSPORT__ = this.previousTransport + this.previousTransport = undefined + } + } +} + +const sharedGlobals = new SharedGlobals() + +/** + * Boots and entry rebuilds run one at a time per worker: the transport global + * must stay the acting client's until its `connection` row applies. + */ +let bootTurn: Promise = Promise.resolve() + +/** Run `work` as the next boot turn; its failure is the caller's, never the next turn's. */ +function takeTurn(work: () => Promise): Promise { + const turn = bootTurn.then(work) + bootTurn = turn.catch(() => undefined) + return turn +} + +/** Default readiness budget; the mock answers `$events` immediately, so a miss means a boot-time fixture is absent. */ +const DEFAULT_CONNECT_TIMEOUT_MS = 5_000 + +/** + * Browser globals jsdom lacks that roster plugins touch at apply or mount: + * client-hmr opens an `EventSource`, layout components observe element size. + * Inert stand-ins, installed only where the global is absent. + */ +const JSDOM_SHIMS: Readonly> = { + EventSource: class { + addEventListener(): void {} + close(): void {} + }, + ResizeObserver: class { + observe(): void {} + disconnect(): void {} + }, +} + +/** Install a shim for each absent global; the disposer deletes exactly those. */ +function installJsdomShims(): () => void { + const globals = globalThis as Record + const installed = Object.keys(JSDOM_SHIMS).filter(name => globals[name] === undefined) + for (const name of installed) globals[name] = JSDOM_SHIMS[name] + return () => { for (const name of installed) Reflect.deleteProperty(globals, name) } +} + +/** The Error a thrown value stands for: itself, or a new Error carrying its string form. */ +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function messageOf(error: unknown): string { + return toError(error).message +} + +function connectionOf(ctx: Context): ConnectionHandle { + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) { + throw new Error('client-test-runtime: the roster provides no `connection` service') + } + return connection +} + +/** Mount point plus whether `start` created it (and so removes it on dispose). */ +interface MountPoint { + readonly element: HTMLElement | undefined + readonly owned: boolean +} + +function resolveMountPoint(mount: boolean | HTMLElement | undefined): MountPoint { + if (mount === undefined || mount === false) return { element: undefined, owned: false } + if (typeof document === 'undefined') { + throw new Error('client-test-runtime: mount requires a DOM; add `// @vitest-environment jsdom` to the spec') + } + if (mount === true) { + const element = document.createElement('div') + document.body.appendChild(element) + return { element, owned: true } + } + return { element: mount, owned: false } +} + +/** Run `fn` inside React `act` when a DOM exists; plain await otherwise. */ +async function settle(fn: () => Promise): Promise { + if (typeof document === 'undefined') { + await fn() + return + } + await act(async () => { await fn() }) +} + +async function awaitConnected(ctx: Context, mock: RemoteMock, timeoutMs: number): Promise { + const { state } = connectionOf(ctx) + if (state.getSnapshot() === 'connected') return + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + unsubscribe() + const unmatched = mock.log.unmatched().map(row => `${row.mode} ${row.endpoint}`) + const streams = mock.log.streams().map(row => `${row.endpoint} (${row.state})`) + reject(new Error( + `client-test-runtime: connection state is ${String(state.getSnapshot())} after ${timeoutMs}ms; ` + + `unmatched: [${unmatched.join(', ')}]; streams: [${streams.join(', ')}]`, + )) + }, timeoutMs) + const unsubscribe = state.subscribe(() => { + if (state.getSnapshot() !== 'connected') return + clearTimeout(timer) + unsubscribe() + resolve() + }) + }) +} + +/** A booted client under test. */ +export class TestClient { + /** + * Load the roster's modules, then, holding this worker's boot turn, install + * the mock as the Connection carrier and the jsdom shims and boot through + * `bootClient` over the synthesized boot graph; afterwards optionally mount + * and wait for the connection. The `@deepseek-ai/dsh-api-remotes` row is + * dropped from the roster: its generated Remote clients exist only in built + * `lib/`, and the `remote.` services the roster injects (plus the + * namespaces the mock has rules for at this point) are provided as + * contract-free proxies over the same Connection instead; a `provide` entry + * for that row is refused. On any failure the context is disposed, an owned + * mount removed, and this client's hold on the globals released before the + * original error is rethrown. + * @param plan - roster and annotations. + * @param mock - Remote mock answering every Gateway call. + * @param options - mount and readiness options. + * @returns the booted client. + */ + static async start( + plan: AssemblyPlan, + mock: RemoteMock, + options: TestClientOptions = {}, + ): Promise { + assertPlan(plan) + if (plan.provide?.[REMOTES_PACKAGE] !== undefined) { + throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote. services are the tier's proxies`) + } + const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE) + ? plan.roster.without([REMOTES_PACKAGE]) + : plan.roster + const ctx = new Context() + const transport: ClientTransportHooks = { rpc: mock.rpc } + let mountPoint: MountPoint = { element: undefined, owned: false } + let release: (() => void) | undefined + const restore = (): void => { + if (mountPoint.owned) mountPoint.element?.remove() + release?.() + } + try { + const modules = await loadPluginModules({ ...plan, roster }) + mountPoint = resolveMountPoint(options.mount) + await takeTurn(async () => { + release = sharedGlobals.acquire(transport) + const system = createInProcessModules(graphFromRoster(roster.rows), modules) + ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as unknown as Plugin) + await bootClient({ ctx, modules: system, manifest: system.manifest }) + }) + if (mountPoint.element !== undefined) { + if (ctx.get('uiRenderer') === undefined) { + throw new Error('client-test-runtime: mount requested, but the roster provides no `uiRenderer`') + } + await mountClient(ctx, mountPoint.element) + } + if (options.awaitConnected !== false) { + await awaitConnected(ctx, mock, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) + } + } catch (error) { + try { + await settle(() => ctx.fiber.dispose()) + } catch { + // The boot failure is the error to report; a half-built tree failing to dispose adds nothing to it. + } finally { + restore() + } + throw error + } + return new TestClient(ctx, mock, mountPoint.element, restore) + } + + private disposing: Promise | undefined + + private constructor( + readonly ctx: Context, + readonly mock: RemoteMock, + readonly container: HTMLElement | undefined, + private readonly restore: () => void, + ) {} + + /** The carrier this client's `connection` row reads when it applies. */ + private get transport(): ClientTransportHooks { + return { rpc: this.mock.rpc } + } + + /** The roster's Connection service (no `Context` augmentation declares it); throws when the roster provides none. */ + get connection(): ConnectionHandle { + return connectionOf(this.ctx) + } + + /** Flush pending React work and microtasks inside `act` (plain microtask flush without a DOM). */ + async flush(): Promise { + await settle(() => Promise.resolve()) + } + + /** + * Rebuild one Loader entry: client-hmr's registry-first fiber teardown, then `entry.refresh()`. The rebuild + * takes the worker's boot turn with this client's carrier installed, so a rebuilt `connection` row reads its + * own mock even while another client is live. Requires a live client: after `dispose()` the Loader holds no + * entries and the lookup throws before anything is installed. + * @param name - package name of the row. + */ + async reload(name: string): Promise { + const entry = this.entryOf(name) + await takeTurn(async () => { + sharedGlobals.install(this.transport) + await tearDownEntryFiber(entry) + await entry.refresh() + await this.ctx.loader.await() + }) + } + + /** + * Remove one Loader entry and wait for its plugin cleanup. + * @param name - package name of the row. + */ + async unload(name: string): Promise { + const entry = this.entryOf(name) + const disposal = entry.fiber?.dispose() + this.ctx.loader.remove(entry.id) + await disposal + } + + /** + * Dispose the plugin tree, then drop an owned mount and release this + * client's hold on the shared globals even when the tree fails to dispose, + * then `mock.assertNoUnmatched()` last so its failure is the test's reason + * without skipping the cleanup; when both the tree and the check fail, one + * error carries both messages. The first call owns the teardown and reports + * its failure; every later call waits for that teardown and resolves. + */ + async dispose(): Promise { + if (this.disposing !== undefined) { + await this.disposing.catch(() => undefined) + return + } + this.disposing = this.teardown() + await this.disposing + } + + private async teardown(): Promise { + let failure: Error | undefined + try { + await settle(() => this.ctx.fiber.dispose()) + } catch (error) { + failure = toError(error) + } finally { + this.restore() + } + if (failure === undefined) { + this.mock.assertNoUnmatched() + return + } + try { + this.mock.assertNoUnmatched() + } catch (unmatched) { + throw new Error(`${messageOf(unmatched)}\nclient-test-runtime: the plugin tree also failed to dispose: ${failure.message}`, { cause: failure }) + } + throw failure + } + + private entryOf(name: string): Entry { + const entries = [...this.ctx.loader.entries()] + const entry = entries.find(candidate => candidate.options.name === name) + if (entry === undefined) { + throw new Error( + `client-test-runtime: no Loader entry named ${name}; entries: ${entries.map(row => row.options.name).join(', ')}`, + ) + } + return entry + } +} diff --git a/packages/test-support/client-runtime/src/assembly/vitest.ts b/packages/test-support/client-runtime/src/assembly/vitest.ts new file mode 100644 index 0000000000..889ee2adcb --- /dev/null +++ b/packages/test-support/client-runtime/src/assembly/vitest.ts @@ -0,0 +1,60 @@ +/** Test-scoped Remote mock and lazy client boot, owned by native Vitest fixtures. */ +import { test, type TestAPI } from 'vitest' +import { RemoteMock, type MockedRemote } from '@deepseek-ai/dsh-remote-mock' +import type { AssemblyPlan } from './roster.ts' +import { remoteDefaultResponses } from './remote-default-responses.ts' +import { TestClient, type TestClientOptions } from './test-client.ts' + +/** Per-test controls; configure the mock before awaiting `start()`. */ +export interface ClientTestFixtures { + /** Fresh mock with the assembly's default responses already loaded. */ + mock: RemoteMock + /** Namespace proxy backed by the same native mocks used by the Connection carrier. */ + remote: MockedRemote + /** Await the one client owned by this test; rejects after the fixture closes. */ + start: () => Promise +} + +/** + * Extend Vitest with a fresh mock and a lazy, automatically disposed client. Repeated `start()` calls share one + * promise. Await it to observe startup failures; cleanup waits for startup but does not rethrow its rejection. + * Missing mock responses still fail teardown even when no client was started. Page globals remain environment-owned. + * @param plan - roster and replacement modules, shared as configuration rather than as a running client. + * @param options - mount and readiness settings passed to `TestClient.start`. + * @returns Vitest's test function with test-scoped `mock` and `start` fixtures. + */ +export function createClientTest(plan: AssemblyPlan, options: TestClientOptions = {}): TestAPI { + return test.extend({ + remote: async ({ mock }, use) => { + await use(mock.remote) + }, + mock: async ({}, use) => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + try { + await use(mock) + } finally { + mock.assertNoUnmatched() + } + }, + start: async ({ mock }, use) => { + let pending: Promise | undefined + let closed = false + const start = (): Promise => { + if (closed) return Promise.reject(new Error('client-test-runtime: start() called after its test fixture closed')) + if (pending === undefined) { + pending = TestClient.start(plan, mock, options) + // Startup belongs to the caller; keep a rejection handler attached until teardown can await it. + void pending.catch(() => undefined) + } + return pending + } + try { + await use(start) + } finally { + closed = true + const client = await pending?.catch(() => undefined) + await client?.dispose() + } + }, + }) +} diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 23bc01bdfb..8472514901 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -45,8 +45,6 @@ export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot export { FixtureSession, TestSessions } from './sessions.ts' export { stubSettingsScope } from './settings-scope.ts' export type { StubSettingsScope } from './settings-scope.ts' -export { scriptedSettingsRemote } from './settings-remote.ts' -export type { ScriptedNamespace, ScriptedSettingsRemote } from './settings-remote.ts' export { TestWorkspaces } from './workspaces.ts' export { RemoteError, TestRemote } from './remote.ts' export { diff --git a/packages/test-support/client-runtime/src/settings-remote.ts b/packages/test-support/client-runtime/src/settings-remote.ts deleted file mode 100644 index 97c2748480..0000000000 --- a/packages/test-support/client-runtime/src/settings-remote.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** Test double for the `settings` Remote namespace a bench's plugins inject. */ -import { vi } from 'vitest' - -/** The minimum a scripted namespace view carries for the double's own bookkeeping. */ -export interface ScriptedNamespace { - /** Namespace key the write addresses. */ - ns: string -} - -/** One scripted `settings` namespace face plus the controls a bench drives it with. */ -export interface ScriptedSettingsRemote { - /** - * The namespace face handed to `TestRemote` as `settings`. A plugin injecting - * `remote.settings` unparks on it, which is what most benches need; the - * describe answer is the same one the shared mirror would read. - */ - settings: { - describe(): Promise<{ ok: true; value: { writable: boolean; hasDocument: boolean; namespaces: readonly View[] } }> - update(ns: string, patch: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - replace(ns: string, section: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - mutate(ns: string, ops: unknown, expectedRevision: number | undefined): Promise< - | { ok: true; value: View } - | { ok: false; error: { code: string; message: string; details: object } } - > - } - /** Spy behind `settings.update`, for argument assertions. */ - update: ReturnType - /** Spy behind `settings.replace`, for argument assertions. */ - replace: ReturnType - /** Spy behind `settings.mutate`, for argument assertions. */ - mutate: ReturnType - /** - * Replace what the next describe answers with, as a Host commit would. - * @param namespaces - the namespace views to serve from now on. - */ - publish(namespaces: readonly View[]): void -} - -/** - * Build a scripted `settings` Remote namespace for a bench. Each write answers - * with the addressed namespace unchanged, so a bench that only needs its - * plugins to activate scripts nothing; one asserting a write reads the - * corresponding spy or replaces the face. - * @param namespaces - namespace views the first describe answers with. - * @param options - deployment facts the describe answer reports. - * @returns the face and its controls. - */ -export function scriptedSettingsRemote( - namespaces: readonly View[] = [], - options: { writable?: boolean; hasDocument?: boolean } = {}, -): ScriptedSettingsRemote { - let served = namespaces - const writable = options.writable ?? true - const hasDocument = options.hasDocument ?? false - const answer = (ns: string) => { - const view = served.find(candidate => candidate.ns === ns) - return Promise.resolve(view === undefined - ? { - ok: false as const, - error: { code: 'settings/rejected', message: `no scripted namespace "${ns}"`, details: { ns } }, - } - : { ok: true as const, value: view }) - } - const update = vi.fn((ns: string, _patch: unknown, _expectedRevision: number | undefined) => answer(ns)) - const replace = vi.fn((ns: string, _section: unknown, _expectedRevision: number | undefined) => answer(ns)) - const mutate = vi.fn((ns: string, _ops: unknown, _expectedRevision: number | undefined) => answer(ns)) - return { - settings: { - describe: () => Promise.resolve({ ok: true as const, value: { writable, hasDocument, namespaces: served } }), - update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision), - replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision), - mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision), - }, - update, - replace, - mutate, - publish(next) { served = next }, - } -} diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts new file mode 100644 index 0000000000..e73ce00818 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -0,0 +1,195 @@ +/** bundleRoster: the real web profile read from its bundles, and every reader decision on a scratch installation. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getStaticModules } from '@deepseek-ai/dsh-client-web/src/seed.ts' +import { afterAll, describe, expect, it } from 'vitest' +import { MODULES_PACKAGE } from '../src/assembly/modules.ts' +import { WEB_PROFILE_BUNDLES, bundleRoster, webApp } from '../src/assembly/bundle-roster.ts' + +describe('webApp (the real web profile)', () => { + it('composes dsh-base then dsh-web-app: unique names, inject edges on roster rows or platform seed words', () => { + expect(WEB_PROFILE_BUNDLES).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + const names = webApp.rows.map(row => row.name) + expect(new Set(names).size).toBe(names.length) + const known = new Set([...names, ...Object.keys(getStaticModules())]) + const dangling = webApp.rows.flatMap(row => row.inject.filter(target => !known.has(target)).map(target => `${row.name} -> ${target}`)) + expect(dangling).toEqual([]) + expect(bundleRoster(WEB_PROFILE_BUNDLES).rows).toEqual(webApp.rows) + }) + + it('keeps browser rows with their declarations and drops Host-only, disabled, and subpath rows', () => { + const immediate = new Set(webApp.rows.filter(row => row.immediately).map(row => row.name)) + expect(immediate.has(MODULES_PACKAGE)).toBe(true) + expect(immediate.has('@deepseek-ai/dsh-client-connection')).toBe(true) + expect(webApp.rows.find(row => row.name === '@deepseek-ai/dsh-api-gateway')?.inject) + .toEqual(['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection']) + const names = webApp.rows.map(row => row.name) + expect(names).toContain('@deepseek-ai/dsh-client-ui-settings-general') + expect(names).not.toContain('@deepseek-ai/dsh-llm') // Host only + expect(names).not.toContain('@deepseek-ai/dsh-client-ui-schedule') // inserted disabled + expect(names).not.toContain('@deepseek-ai/dsh-web-app') // Host runtime glue, its `/startup` row is a subpath + }) +}) + +/** A scratch installation: an anchor package, bundles under its node_modules, plugin packages beside them. */ +class Scratch { + readonly root = mkdtempSync(join(tmpdir(), 'bundle-roster-')) + readonly anchor = join(this.root, 'app', 'package.json') + + constructor() { + mkdirSync(join(this.root, 'app'), { recursive: true }) + writeFileSync(this.anchor, JSON.stringify({ name: 'app' })) + } + + pkg(name: string, manifest: Record, files: Record = {}): void { + const dir = join(this.root, 'app', 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, ...manifest })) + for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content) + } + + bundle(name: string, patch: string): void { + this.pkg(name, { dsh: { bundle: { patch: './cordis.patch.yml' } } }, { 'cordis.patch.yml': patch }) + } + + web(name: string, client: Record = {}): void { + this.pkg(name, { dsh: { client: { platform: 'web', ...client } } }) + } + + roster(bundles: readonly string[]): readonly string[] { + return bundleRoster(bundles, this.anchor).rows.map(row => row.name) + } +} + +describe('bundleRoster on a scratch installation', () => { + const scratch = new Scratch() + afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) }) + + it('applies the layers in order and keeps enabled browser rows once, with their dsh.client declaration', () => { + scratch.web('@t/a', { inject: ['@t/b'], immediately: true }) + scratch.web('@t/b') + scratch.web('@t/c') + scratch.web('plain') + scratch.pkg('@t/host', { dsh: {} }) + scratch.pkg('@t/node', { dsh: { client: { platform: 'node' } } }) + scratch.bundle('@t/base', ` +- insert: + - id: a + name: '@t/a' + config: + root: !!js process.cwd() + - id: host + name: '@t/host' + disabled: !!js process.platform === 'win32' + - id: node + name: '@t/node' + - id: c + name: '@t/c' + - id: sub + name: '@t/a/extra' + - id: builtin + name: 'cordis:group' +`) + scratch.bundle('@t/web', ` +- id: c + disabled: true +- insert: + - id: b + name: '@t/b' + - id: b-again + name: '@t/b' + - id: plain + name: plain + - id: off + name: '@t/off' + disabled: true +`) + const roster = bundleRoster(['@t/base', '@t/web'], scratch.anchor) + expect(roster.rows).toEqual([ + { name: '@t/a', inject: ['@t/b'], immediately: true }, + { name: '@t/b', inject: [], immediately: false }, + { name: 'plain', inject: [], immediately: false }, + ]) + expect(scratch.roster(['@t/base'])).toEqual(['@t/a', '@t/c']) + }) + + it('descends into Loader groups and lets a disabled group disable every row beneath it', () => { + scratch.web('@t/grouped') + scratch.web('@t/grouped-off') + scratch.web('@t/nested') + scratch.bundle('@t/groups', ` +- insert: + - id: on + name: cordis:group + group: true + config: + - id: grouped + name: '@t/grouped' + - id: inner + name: cordis:group + group: true + config: + - id: nested + name: '@t/nested' + - id: off + name: cordis:group + group: true + disabled: true + config: + - id: grouped-off + name: '@t/grouped-off' +`) + expect(scratch.roster(['@t/groups'])).toEqual(['@t/grouped', '@t/nested']) + }) + + it('refuses a browser row whose disabled value is a !!js expression', () => { + scratch.web('@t/maybe') + scratch.bundle('@t/maybe-bundle', ` +- insert: + - id: maybe + name: '@t/maybe' + disabled: !!js process.platform === 'win32' +`) + expect(() => scratch.roster(['@t/maybe-bundle'])).toThrow('browser row @t/maybe has a `disabled` value this reader cannot evaluate') + }) + + it('fails loud on a bundle that does not resolve, declares no patch, or whose patch is not a list', () => { + expect(() => scratch.roster(['@t/missing'])).toThrow('cannot resolve bundle @t/missing from') + scratch.pkg('@t/no-patch', { dsh: {} }) + expect(() => scratch.roster(['@t/no-patch'])).toThrow('bundle @t/no-patch declares no dsh.bundle.patch in') + scratch.bundle('@t/not-a-list', 'insert: []\n') + expect(() => scratch.roster(['@t/not-a-list'])).toThrow('must be a top-level list of patches') + }) + + it('fails loud on a patch that does not apply as written', () => { + scratch.bundle('@t/dangling', ` +- id: nowhere + disabled: true +`) + expect(() => scratch.roster(['@t/dangling'])).toThrow('bundle patch patch: entry "nowhere" not found') + }) + + it('fails loud on an enabled row whose package does not resolve or names another package', () => { + scratch.bundle('@t/unresolved', ` +- insert: + - id: ghost + name: '@t/ghost' +`) + expect(() => scratch.roster(['@t/unresolved'])).toThrow('cannot resolve plugin package @t/ghost from @t/unresolved') + scratch.bundle('@t/builtin', ` +- insert: + - id: fs + name: fs +`) + // A Node builtin name has no resolution paths at all. + expect(() => scratch.roster(['@t/builtin'])).toThrow('cannot resolve plugin package fs from @t/builtin') + scratch.pkg('@t/alias', { name: '@t/real' }) + scratch.bundle('@t/misnamed', ` +- insert: + - id: alias + name: '@t/alias' +`) + expect(() => scratch.roster(['@t/misnamed'])).toThrow('names "@t/real", expected @t/alias') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts new file mode 100644 index 0000000000..4bd4835b59 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-dependencies.client.spec.ts @@ -0,0 +1,37 @@ +/** Real bundle dependency closures activate their requested client plugins without extra roster rows. */ +import { FiberState } from '@deepseek-ai/cordis' +import { describe, expect, vi } from 'vitest' +import { createClientTest, webApp } from '../src/assembly/index.ts' + +const HMR = '@deepseek-ai/dsh-client-hmr' +const MODULES = '@deepseek-ai/dsh-client-modules' +const SESSIONS = '@deepseek-ai/dsh-api-session-controller' +const FILE_UPLOAD = '@deepseek-ai/dsh-client-file-upload' +const hmrRoster = webApp.closure([HMR]) +const sessionRoster = webApp.closure([SESSIONS]) +const hmrTest = createClientTest({ roster: hmrRoster }, { awaitConnected: false }) +const sessionTest = createClientTest({ roster: sessionRoster }) + +describe('bundle dependency closures', () => { + hmrTest('activates HMR with its declared module provider and no Connection', async ({ start }) => { + expect(hmrRoster.rows.map(row => row.name)).toContain(MODULES) + const client = await start() + const entry = [...client.ctx.loader.entries()].find(row => row.options.name === HMR) + expect(entry?.fiber?.state).toBe(FiberState.ACTIVE) + expect(client.ctx.get('modules')).toBeDefined() + expect(client.ctx.get('connection')).toBeUndefined() + }, 60_000) + + sessionTest('activates Session Controller with its declared file-upload provider', async ({ mock, start }) => { + expect(sessionRoster.rows.map(row => row.name)).toContain(FILE_UPLOAD) + const client = await start() + const entry = [...client.ctx.loader.entries()].find(row => row.options.name === SESSIONS) + expect(entry?.fiber?.state).toBe(FiberState.ACTIVE) + expect(client.ctx.get('fileUpload')).toBeDefined() + await vi.waitFor(() => { + expect(client.ctx.sessions.list.getSnapshot().phase).toBe('ready') + expect(mock.log.calls('session/list')).toHaveLength(1) + expect(mock.log.streams('session/control')).toHaveLength(1) + }) + }, 60_000) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts new file mode 100644 index 0000000000..999a99ea70 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts @@ -0,0 +1,105 @@ +/** + * In-process module arrival: dynamic `/client` imports resolve through the + * repository path aliases, provided rows replace them, the bootstrap row is the + * static namespace, and the resulting module system serves the vendored Loader + * without ever loading a bundle. + */ +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import * as modulesClient from '@deepseek-ai/dsh-client-modules/client' +import { parseBootManifest } from '@deepseek-ai/dsh-client-modules/client' +import type { WebBootGraph } from '@deepseek-ai/dsh-client-modules/client' +import * as uiRenderer from '@deepseek-ai/dsh-client-ui-renderer/client' +import * as typertRegistry from '@deepseek-ai/dsh-typert-registry/client' +import { describe, expect, it, onTestFinished } from 'vitest' +import { ClientRoster } from '../src/assembly/index.ts' +import { MODULES_PACKAGE, createInProcessModules, loadPluginModules } from '../src/assembly/modules.ts' + +const RENDERER = '@deepseek-ai/dsh-client-ui-renderer' +const TYPERT = '@deepseek-ai/dsh-typert-registry' +const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' +const MISSING = '@deepseek-ai/dsh-client-does-not-exist' + +const row = (name: string, immediately = false) => ({ name, inject: [], immediately }) + +function graph(names: readonly string[]): WebBootGraph { + return { + rev: 'local', + entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })), + batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }], + } +} + +describe('loadPluginModules', () => { + it('imports real rows dynamically, takes provided rows from the plan, and pins the bootstrap row', async () => { + const provided = { apply: () => {} } + const roster = ClientRoster.of([row(TYPERT), row(MODULES_PACKAGE), row(BRAND), row(RENDERER)]) + const modules = await loadPluginModules({ roster, provide: { [BRAND]: provided } }) + expect([...modules.keys()]).toEqual([TYPERT, MODULES_PACKAGE, BRAND, RENDERER]) + expect(modules.get(TYPERT)).toBe(typertRegistry) + expect(modules.get(RENDERER)).toBe(uiRenderer) + expect(modules.get(MODULES_PACKAGE)).toBe(modulesClient) + expect(modules.get(BRAND)).toBe(provided) + }) + + it('prefixes the package name when a /client import fails', async () => { + const roster = ClientRoster.of([row(MISSING)]) + await expect(loadPluginModules({ roster })).rejects.toThrow( + new RegExp(`^client-test-runtime: cannot import ${MISSING}/client: .+`), + ) + }) + + it('refuses a provided bootstrap row', async () => { + const roster = ClientRoster.of([row(MODULES_PACKAGE)]) + await expect(loadPluginModules({ roster, provide: { [MODULES_PACKAGE]: { apply: () => {} } } })) + .rejects.toThrow(`${MODULES_PACKAGE} is the bootstrap module and cannot be provided`) + }) +}) + +describe('createInProcessModules', () => { + it('serves import() and prefetch() from registered factories and never loads a bundle', async () => { + const roster = ClientRoster.of([row(MODULES_PACKAGE, true), row(TYPERT, true), row(RENDERER)]) + const modules = await loadPluginModules({ roster }) + const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules) + + await expect(system.prefetch(TYPERT)).resolves.toBeUndefined() + await expect(system.prefetch(RENDERER)).resolves.toBeUndefined() + await expect(system.prefetch(MODULES_PACKAGE)).resolves.toBeUndefined() + expect(system.loadCache.has(TYPERT)).toBe(false) + expect(system.loadCache.has(RENDERER)).toBe(false) + await expect(system.import(RENDERER, '', {})).resolves.toBe(uiRenderer) + await expect(system.import(`${TYPERT}/client`, '', {})).resolves.toBe(typertRegistry) + await expect(system.import(MODULES_PACKAGE, '', {})).resolves.toBe(modulesClient) + expect(system.loadCache.get(TYPERT)?.exports).toBe(typertRegistry) + expect(system.loadCache.get(RENDERER)?.exports).toBe(uiRenderer) + await expect(system.import('@deepseek-ai/dsh-client-unknown', '', {})).rejects.toThrow('cannot resolve') + }) + + it('rejects loudly instead of fetching when a graph row has no loaded module', async () => { + const system = createInProcessModules(graph(['ghost']), new Map()) + await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + }) + + it('exposes the graph parsed by the production validator as its manifest', () => { + const raw = graph(['a', 'b']) + expect(createInProcessModules(raw, new Map()).manifest).toEqual(parseBootManifest(raw)) + expect(() => createInProcessModules({ ...raw, batches: [] }, new Map())).toThrow('belongs to no initial-load batch') + }) + + it('boots the vendored Loader over the in-process table with every entry active', async () => { + const roster = ClientRoster.of([row(TYPERT, true), row(RENDERER, true), row(MODULES_PACKAGE, true)]) + const modules = await loadPluginModules({ roster }) + const system = createInProcessModules(graph(roster.rows.map(r => r.name)), modules) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(Loader) + ctx.loader.internal = system as never + await Promise.all(roster.rows.map(r => ctx.loader.create({ name: r.name }))) + await ctx.loader.await() + expect(ctx.get('typert')).toBeDefined() + expect(ctx.get('slots')).toBeDefined() + expect(ctx.get('modules')).toBe(system) + expect([...ctx.loader.entries()].every(entry => entry.fiber !== undefined)).toBe(true) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts new file mode 100644 index 0000000000..502a0f8fe7 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-plan.client.spec.ts @@ -0,0 +1,22 @@ +/** assertPlan: provide keys must name roster rows. */ +import { describe, expect, it } from 'vitest' +import { assertPlan } from '../src/assembly/roster.ts' +import { ClientRoster } from '../src/assembly/roster.ts' + +const roster = ClientRoster.of([ + { name: 'a', inject: [], immediately: false }, + { name: 'b', inject: [], immediately: false }, +]) +const plugin = { apply: () => {} } + +describe('assertPlan', () => { + it('accepts a bare roster and provided roster rows', () => { + expect(() => { assertPlan({ roster }) }).not.toThrow() + expect(() => { assertPlan({ roster, provide: { a: plugin, b: plugin } }) }).not.toThrow() + }) + + it('rejects provide keys outside the roster and lists the roster', () => { + expect(() => { assertPlan({ roster, provide: { a: plugin, zz: plugin, yy: plugin } }) }) + .toThrow('provide names rows outside the roster: zz, yy; roster: a, b') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts new file mode 100644 index 0000000000..7ae3666000 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-remote-proxies.client.spec.ts @@ -0,0 +1,93 @@ +/** Remote proxies: namespace discovery from roster injects and mock rules, and per-call routing over the Connection to the mock. */ +import { RemoteMock, frames, ok, openStream } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished } from 'vitest' +import type { ClientPluginModule } from '../src/assembly/index.ts' +import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' +import { remoteNamespacesOf } from '../src/assembly/remote-proxies.ts' + +/** The api-remotes row and its cone (Gateway client, Typert registry, Connection); TestClient drops the api-remotes row itself. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-remotes']) + +type RemoteFace = Record unknown>> + +async function drain(source: AsyncIterable): Promise { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} + +describe('remoteNamespacesOf', () => { + it('collects remote. injects in both inject forms plus the namespaces of registered endpoints, skipping Gateway-internal ones', () => { + const modules: ClientPluginModule[] = [ + { apply() {}, inject: ['slots', 'remote.settings', 'remote'] }, + { apply() {}, inject: { 'remote.session': { required: true }, connection: { required: false } } }, + { apply() {} }, + ] + const mock = RemoteMock.create().unary('goals/create', ok({ id: 'g' })).stream('workspace/follow', openStream()) + expect(remoteNamespacesOf(modules, mock)).toEqual(['goals', 'session', 'settings', 'workspace']) + }) +}) + +describe('remote proxies over a booted client', () => { + async function booted(configure: (mock: RemoteMock) => void = () => {}) { + const mock = RemoteMock.create().load(remoteDefaultResponses) + configure(mock) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + return { client, mock, remote: (client.ctx as unknown as { remote: RemoteFace }).remote } + } + + it('drops the api-remotes row and answers ctx.remote.. with the mock value, logging positional args', async () => { + const { client, mock, remote } = await booted((m) => { + m.unary('session/rename', ok({ title: 'renamed', seq: 3 })) + m.unary('session/cancel', { ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } }) + }) + expect([...client.ctx.loader.entries()].map(entry => entry.options.name)).not.toContain('@deepseek-ai/dsh-api-remotes') + await expect(remote.session!.rename!({ sessionId: 's1', title: 'renamed' })).resolves.toEqual({ ok: true, value: { title: 'renamed', seq: 3 } }) + await expect(remote.session!.cancel!({ sessionId: 's1' }, new AbortController().signal)) + .resolves.toEqual({ ok: false, error: { code: 'session/not-found', message: 'gone', details: {} } }) + expect(mock.log.calls('session/rename').map(call => call.args)).toEqual([[{ sessionId: 's1', title: 'renamed' }]]) + expect(mock.log.calls('session/cancel').map(call => call.args)).toEqual([[{ sessionId: 's1' }]]) + expect((remote.session as unknown as { then?: unknown }).then).toBeUndefined() + expect((remote.session as unknown as Record)[Symbol.toStringTag]).toBeUndefined() + }, 60_000) + + it('folds a call without a rule into gateway/internal and leaves the miss in the log for dispose() to report', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const remote = (client.ctx as unknown as { remote: RemoteFace }).remote + await expect(remote.session!.search!({ query: 'x' })).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/internal', message: expect.stringMatching(/^client api: session\/search failed: remote-mock: no rule for session\/search; registered: \$events, /) as string }, + }) + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/search', mode: 'unary' }]) + await expect(client.dispose()).rejects.toThrow('session/search (unary)') + }) + + it('folds a rule rejection like the generated client: gateway/internal, or gateway/cancelled once the signal aborted', async () => { + const { mock, remote } = await booted((m) => { + m.unary('session/rename', () => Promise.reject(new Error('wire down'))) + m.unary('session/cancel', () => new Promise(() => {})) + }) + await expect(remote.session!.rename!({ sessionId: 's1', title: 't' })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'client api: session/rename failed: wire down' }, + }) + const controller = new AbortController() + const cancelling = remote.session!.cancel!({ sessionId: 's1' }, controller.signal) as Promise + controller.abort() + await expect(cancelling).resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) + expect(mock.log.calls('session/rename').map(call => call.state)).toEqual(['failed']) + expect(mock.log.calls('session/cancel').map(call => call.state)).toEqual(['pending']) + }) + + it('opens registered streams and hands their items and failures through unchanged', async () => { + const { remote } = await booted((m) => { + m.stream('session/follow', frames([{ type: 'a' }, { type: 'b' }])) + m.stream('session/control', ((_args, stream) => { stream.fail(new Error('flap')) })) + }) + const signal = new AbortController().signal + await expect(drain(remote.session!.follow!({ address: { kind: 'session', sessionId: 's1' } }, signal) as AsyncIterable)) + .resolves.toEqual([{ type: 'a' }, { type: 'b' }]) + await expect(drain(remote.session!.control!({}) as AsyncIterable)).rejects.toThrow('flap') + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts new file mode 100644 index 0000000000..d1ccea2644 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts @@ -0,0 +1,112 @@ +/** ClientRoster: construction, duplicate refusal, order-preserving pick/closure/without with loud unknown names; graphFromRoster. */ +import { createClientModuleSystem, parseBootManifest, type ClientBundleRegistration, type ClientModuleLoaderTarget } from '@deepseek-ai/dsh-client-modules/client' +import { describe, expect, it } from 'vitest' +import { ClientRoster, graphFromRoster, type ClientRosterRow } from '../src/assembly/roster.ts' +import { MODULES_PACKAGE } from '../src/assembly/modules.ts' + +const ROWS: readonly ClientRosterRow[] = [ + { name: MODULES_PACKAGE, inject: [], immediately: true }, + { name: '@x/a', inject: [], immediately: false }, + { name: '@x/b', inject: ['@x/a'], immediately: true }, +] + +describe('graphFromRoster', () => { + it('synthesizes one application batch over every row with placeholder URLs', () => { + expect(graphFromRoster(ROWS)).toEqual({ + rev: 'local', + entries: [ + { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, + { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' }, + { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, + ], + batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], + }) + }) + + it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => { + const manifest = parseBootManifest(graphFromRoster(ROWS)) + expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately }))) + expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js']) + const row = ROWS[1]! + expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"') + expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array') + }) + + it('imports seeded rows without reaching the bundle transport', async () => { + const loaded: string[] = [] + const pendingQueue: ClientBundleRegistration[] = [] + const target: ClientModuleLoaderTarget = { + mode: 'queue', + pendingQueue, + load: (registration) => { pendingQueue.push(registration) }, + create: options => createClientModuleSystem(target, { id: MODULES_PACKAGE, exports: {} }, options), + } + const a = { apply() {} } + const b = { apply() {} } + const modules = target.create({ + boot: graphFromRoster(ROWS), + staticModules: { '@x/a': a, '@x/b': b }, + loadBundle: async (url) => { loaded.push(url) }, + }) + await expect(modules.import('@x/a')).resolves.toBe(a) + await expect(modules.import('@x/b')).resolves.toBe(b) + expect(modules.manifest).toEqual(parseBootManifest(graphFromRoster(ROWS))) + expect(loaded).toEqual([]) + }) +}) + +const A = { name: 'a', inject: [], immediately: true } +const B = { name: 'b', inject: ['a'], immediately: false } +const C = { name: 'c', inject: ['b'], immediately: false } + +describe('ClientRoster', () => { + it('freezes a copy of the rows in composition order', () => { + const rows = [A, B, C] + const roster = ClientRoster.of(rows) + expect(roster.rows).toEqual([A, B, C]) + expect(roster.rows).not.toBe(rows) + expect(Object.isFrozen(roster.rows)).toBe(true) + rows.push(A) + expect(roster.rows).toHaveLength(3) + }) + + it('refuses duplicate names, listing each duplicate once', () => { + expect(() => ClientRoster.of([A, B, A, C, B, A])).toThrow('duplicate roster rows: a, b') + }) + + it('pick keeps roster order regardless of the requested order', () => { + const roster = ClientRoster.of([A, B, C]) + expect(roster.pick(['c', 'a']).rows.map(row => row.name)).toEqual(['a', 'c']) + expect(roster.pick([]).rows).toEqual([]) + expect(Object.isFrozen(roster.pick(['b']).rows)).toBe(true) + }) + + it('without drops the named rows', () => { + const roster = ClientRoster.of([A, B, C]) + expect(roster.without(['b']).rows.map(row => row.name)).toEqual(['a', 'c']) + expect(roster.without(['a', 'b', 'c']).rows).toEqual([]) + }) + + it('closure keeps the named rows and everything they inject, transitively, in roster order', () => { + const D = { name: 'd', inject: [], immediately: false } + const roster = ClientRoster.of([A, D, B, C]) + expect(roster.closure(['c']).rows.map(row => row.name)).toEqual(['a', 'b', 'c']) + expect(roster.closure(['b', 'd']).rows.map(row => row.name)).toEqual(['a', 'd', 'b']) + expect(roster.closure(['a']).rows.map(row => row.name)).toEqual(['a']) + expect(roster.closure([]).rows).toEqual([]) + expect(Object.isFrozen(roster.closure(['c']).rows)).toBe(true) + }) + + it('pick, closure, and without throw on unknown names with the roster listed; closure also refuses an inject outside the roster', () => { + const roster = ClientRoster.of([A, B]) + expect(() => roster.pick(['a', 'zz'])).toThrow('pick() names outside the roster: zz; roster: a, b') + expect(() => roster.closure(['zz'])).toThrow('closure() names outside the roster: zz; roster: a, b') + expect(() => roster.without(['x', 'y'])).toThrow('without() names outside the roster: x, y; roster: a, b') + expect(() => ClientRoster.of([B]).closure(['b'])).toThrow('b injects a, which is outside the roster') + }) + + it('closure treats the platform seed modules as satisfied without a row', () => { + const seeded = { name: 'seeded', inject: ['@deepseek-ai/dsh-client-ui-primitives', 'react', 'a'], immediately: false } + expect(ClientRoster.of([A, seeded]).closure(['seeded']).rows.map(row => row.name)).toEqual(['a', 'seeded']) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts new file mode 100644 index 0000000000..26afee3272 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -0,0 +1,110 @@ +/** TestClient without a DOM: the api roster boots and connects; mount is refused; flush degrades to a microtask flush. */ +import type { ClientTransportHooks } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' + +/** The Gateway client and what it injects: the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const TYPERT_ONLY = webApp.pick(['@deepseek-ai/dsh-typert-registry']) +const globals = globalThis as { __DSH_TRANSPORT__?: ClientTransportHooks } + +describe('TestClient (node environment)', () => { + it('boots the api roster, connects, and flushes without a DOM', async () => { + expect(typeof document).toBe('undefined') + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + await client.flush() + }, 60_000) + + it('releases the shared globals even when the plugin tree fails to dispose, and still rethrows', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber) + const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => { + await dispose() + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercises normalization of non-Error teardown failures. + return Promise.reject('teardown boom') + }) + onTestFinished(async () => { + failure.mockRestore() + try { await dispose() } finally { await client.dispose() } + }) + await expect(client.dispose()).rejects.toThrow('teardown boom') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + await client.dispose() // idempotent after a failed teardown + }) + + it('rethrows a row that fails to apply, releases the globals, and lets the next boot take its turn', async () => { + const failing = { apply(): void { throw new Error('apply boom') } } + await expect(TestClient.start({ roster: TYPERT_ONLY, provide: { '@deepseek-ai/dsh-typert-registry': failing } }, RemoteMock.create())) + .rejects.toThrow(/apply boom|typert-registry/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + }) + + it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => { + const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes']) + await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create())) + .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote. services are the tier\'s proxies') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const dispose = client.ctx.fiber.dispose.bind(client.ctx.fiber) + const failure = vi.spyOn(client.ctx.fiber, 'dispose').mockImplementation(async () => { + await dispose() + throw new Error('teardown boom') + }) + onTestFinished(async () => { + failure.mockRestore() + try { await dispose() } finally { await client.dispose() } + }) + await mock.dispatch('nowhere/call', []).catch(() => undefined) + await expect(client.dispose()).rejects.toThrow(/nowhere\/call \(unary\)[\s\S]*also failed to dispose: teardown boom/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + }) + + it('lets a concurrent second dispose wait for the first teardown instead of returning early', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + const first = client.dispose() + const second = client.dispose() + await second + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + await first + }) + + it('refuses to mount without a DOM before installing the transport', async () => { + await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create(), { mount: true })) + .rejects.toThrow('mount requires a DOM') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('fails loud, then restores the transport, when the roster cannot provide a connection', async () => { + await expect(TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create())) + .rejects.toThrow('provides no `connection` service') + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('skips readiness on request and restores a pre-existing transport on dispose', async () => { + const previous: ClientTransportHooks = { fetch: () => Promise.reject(new Error('unused')) } + globals.__DSH_TRANSPORT__ = previous + onTestFinished(() => { delete globals.__DSH_TRANSPORT__ }) + const client = await TestClient.start({ roster: TYPERT_ONLY }, RemoteMock.create(), { awaitConnected: false }) + expect(globals.__DSH_TRANSPORT__).not.toBe(previous) + expect(client.ctx.get('typert')).toBeDefined() + expect(() => client.connection).toThrow('provides no `connection` service') + await client.dispose() + expect(globals.__DSH_TRANSPORT__).toBe(previous) + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts new file mode 100644 index 0000000000..b4d28d9fc6 --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -0,0 +1,195 @@ +// @vitest-environment jsdom +/** + * TestClient over the web profile's roster read from its bundles: production + * `bootClient` over in-process modules, every Remote call answered by a + * `RemoteMock` installed as the Connection carrier, mount, HMR-style reload, + * unload, and fail-loud teardown. + */ +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import { RemoteMock, ok, openStream } from '@deepseek-ai/dsh-remote-mock' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { AssemblyPlan, TestClientOptions } from '../src/assembly/index.ts' +import { ClientRoster, TestClient, remoteDefaultResponses, webApp } from '../src/assembly/index.ts' + +/** The Gateway client and what it injects: the Typert registry and the Connection. */ +const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway']) +const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' +/** Declared by ui-sidebar, whose SlotMap merge is outside this package's compilation face. */ +const SIDEBAR_SETTINGS = 'sidebar.settings' as never +const BRAND = '@deepseek-ai/dsh-client-ui-brand-official' +const globals = globalThis as { __DSH_TRANSPORT__?: unknown; EventSource?: unknown; ResizeObserver?: unknown } +/** The whole roster's first boot pays the cold module transform of every plugin package. */ +const COLD_BOOT_TIMEOUT_MS = 60_000 + +async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start(plan, mock, options) + onTestFinished(() => client.dispose()) + return client +} + +describe('TestClient (jsdom)', () => { + it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp }, mock, { mount: true }) + expect(client.connection.state.getSnapshot()).toBe('connected') + expect(mock.log.streams('$events')).toHaveLength(1) + const container = client.container! + expect(document.body.contains(container)).toBe(true) + expect(container.childElementCount).toBeGreaterThan(0) + expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect(globals.EventSource).toBeDefined() + expect(globals.ResizeObserver).toBeDefined() + await client.dispose() + expect(document.body.contains(container)).toBe(false) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + expect(globals.ResizeObserver).toBeUndefined() + await client.dispose() + }, COLD_BOOT_TIMEOUT_MS) + + it('leaves a pre-existing global alone and removes only the shims it installed', async () => { + const existing = { existing: true } + vi.stubGlobal('ResizeObserver', existing) + onTestFinished(() => { vi.unstubAllGlobals() }) + const client = await started({ roster: API_ROSTER }) + expect(globals.ResizeObserver).toBe(existing) + expect(globals.EventSource).toBeDefined() + await client.dispose() + expect(globals.ResizeObserver).toBe(existing) + expect(globals.EventSource).toBeUndefined() + }) + + it('boots overlapping clients one at a time onto their own mocks and keeps the shared globals until the last dispose', async () => { + const mockA = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'a', seq: 1 })) + const mockB = RemoteMock.create().load(remoteDefaultResponses).unary('session/rename', ok({ title: 'b', seq: 1 })) + const [a, b] = await Promise.all([ + TestClient.start({ roster: API_ROSTER }, mockA), + TestClient.start({ roster: API_ROSTER }, mockB), + ]) + onTestFinished(() => b.dispose()) + const rename = async (client: TestClient): Promise => + (client.ctx as unknown as { remote: { session: { rename(request: unknown): Promise } } }).remote.session.rename({ sessionId: 's', title: 't' }) + await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) + await expect(rename(b)).resolves.toEqual({ ok: true, value: { title: 'b', seq: 1 } }) + expect(mockA.log.calls('session/rename')).toHaveLength(1) + expect(mockB.log.calls('session/rename')).toHaveLength(1) + // A rebuilt connection row reads its own mock even after another client installed the transport last. + await a.reload('@deepseek-ai/dsh-client-connection') + await vi.waitFor(() => { expect(a.connection.state.getSnapshot()).toBe('connected') }) + await expect(rename(a)).resolves.toEqual({ ok: true, value: { title: 'a', seq: 1 } }) + expect(mockA.log.calls('session/rename')).toHaveLength(2) + expect(mockB.log.calls('session/rename')).toHaveLength(1) + await a.dispose() + expect(globals.__DSH_TRANSPORT__).toBeDefined() + expect(globals.EventSource).toBeDefined() + await b.dispose() + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + }) + + it('boots the api subset without a mount and exposes the typed Remote', async () => { + const client = await started({ roster: API_ROSTER }) + expect(client.container).toBeUndefined() + expect(client.ctx.remote.session).toBeDefined() + expect(client.connection.state.getSnapshot()).toBe('connected') + }) + + it('refuses to mount a roster that provides no uiRenderer instead of returning an empty container', async () => { + const before = document.body.childElementCount + await expect(TestClient.start({ roster: API_ROSTER }, RemoteMock.create().load(remoteDefaultResponses), { mount: true })) + .rejects.toThrow('mount requested, but the roster provides no `uiRenderer`') + expect(document.body.childElementCount).toBe(before) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('creates no mount element when the roster cannot be loaded', async () => { + const roster = ClientRoster.of([{ name: '@deepseek-ai/dsh-client-test-runtime-missing', inject: [], immediately: true }]) + const before = document.body.childElementCount + await expect(TestClient.start({ roster }, RemoteMock.create(), { mount: true })).rejects.toThrow() + expect(document.body.childElementCount).toBe(before) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + }) + + it('mounts into a caller-supplied element and leaves it in place on dispose', async () => { + const host = document.createElement('main') + document.body.appendChild(host) + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp }, mock, { mount: host }) + expect(client.container).toBe(host) + expect(host.childElementCount).toBeGreaterThan(0) + await client.dispose() + expect(document.body.contains(host)).toBe(true) + host.remove() + }) + + it('boots with a provided row in place of the real plugin', async () => { + const apply = vi.fn() + const client = await started({ roster: webApp, provide: { [BRAND]: { apply } } }, { mount: true }) + expect(apply).toHaveBeenCalledOnce() + expect([...client.ctx.loader.entries()].some(entry => entry.options.name === BRAND)).toBe(true) + }) + + it('reload rebuilds the declaring entry; unload collapses it', async () => { + const client = await started({ roster: webApp }, { mount: true }) + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) + await client.reload(SIDEBAR) + await client.flush() + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(1) + const cleanupStarted = Promise.withResolvers() + const releaseCleanup = Promise.withResolvers() + const sidebar = [...client.ctx.loader.entries()].find(entry => entry.options.name === SIDEBAR)! + sidebar.fiber!.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await releaseCleanup.promise + }) + let unloaded = false + const unloading = client.unload(SIDEBAR).then(() => { unloaded = true }) + try { + await cleanupStarted.promise + await client.flush() + expect(unloaded).toBe(false) + } finally { + releaseCleanup.resolve(undefined) + await unloading + } + expect(unloaded).toBe(true) + await client.flush() + expect(client.ctx.slots.entries(SIDEBAR_SETTINGS)).toHaveLength(0) + await expect(client.reload(SIDEBAR)).rejects.toThrow(`no Loader entry named ${SIDEBAR}`) + }) + + it('fails loud by teardown at the latest when a boot-time endpoint has no fixture', async () => { + const mock = RemoteMock.create() + const run = TestClient.start({ roster: webApp }, mock, { mount: true }).then(client => client.dispose()) + await expect(run).rejects.toThrow(/session\/control|workspace\/follow|session\/list|settings\/describe/) + }) + + it('waits through a carrier flap for the connection to become ready', async () => { + let opens = 0 + const mock = RemoteMock.create().load(remoteDefaultResponses).stream('$events', (_args, stream) => { + opens += 1 + const first = opens === 1 + setTimeout(() => { + if (first) stream.fail(new Error('flap')) + // Branded on the Gateway side; the test mints a plain string. + else stream.push({ type: 'ready', clientId: 'reconnected' as never, host: { home: '/home/mock' } }) + }, 20) + }) + const client = await TestClient.start({ roster: API_ROSTER }, mock) + onTestFinished(() => client.dispose()) + expect(client.connection.state.getSnapshot()).toBe('connected') + expect(mock.log.streams('$events')).toHaveLength(2) + }) + + it('reports the log when the connection never becomes ready', async () => { + // No fixtures: workspace-controller's follow has no rule, so the proxy dispatches it as a unary call the mock + // logs as unmatched, while $events never sends ready. + const roster = webApp.closure(['@deepseek-ai/dsh-api-workspace-controller']) + const mock = RemoteMock.create().stream('$events', openStream([])) + await expect(TestClient.start({ roster }, mock, { connectTimeoutMs: 300 })) + .rejects.toThrow(/connection state is \S+ after 300ms; unmatched: \[unary workspace\/follow\]; streams: \[.*\$events \(open\).*\]/) + expect(globals.__DSH_TRANSPORT__).toBeUndefined() + expect(globals.EventSource).toBeUndefined() + }) +}) diff --git a/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts new file mode 100644 index 0000000000..8a31f305ad --- /dev/null +++ b/packages/test-support/client-runtime/tests/assembly-vitest.client.spec.ts @@ -0,0 +1,64 @@ +/** Native fixture cleanup remains active after assertion failure and without a started client. */ +import { afterAll, describe, expect } from 'vitest' +import { ok } from '@deepseek-ai/dsh-remote-mock' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createClientTest, webApp, type TestClient } from '../src/assembly/index.ts' + +const test = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) }) +const clients: TestClient[] = [] +const expired: (() => Promise)[] = [] + +afterAll(async () => { + expect(clients).toHaveLength(2) + for (const client of clients) { + expect(client.ctx.get('loader')).toBeUndefined() + expect(client.mock.log.streams('$events').map(stream => stream.state)).toEqual(['cancelled']) + } + for (const start of expired) { + await expect(start()).rejects.toThrow('after its test fixture closed') + } + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) +}) + +describe('createClientTest', () => { + test('allows response configuration before boot and shares concurrent starts', async ({ mock, remote, start }) => { + expired.push(start) + expect(remote).toBe(mock.remote) + remote.session.rename.mockResolvedValueOnce(ok({ title: 'test title', seq: 1 })) + const first = start() + const second = start() + expect(second).toBe(first) + const client = await first + clients.push(client) + expect(client.mock).toBe(mock) + expect(mock.log.streams('$events')).toHaveLength(1) + await expect(client.ctx.remote.session.rename({ sessionId: 's' as SessionId, title: 't' })) + .resolves.toEqual(ok({ title: 'test title', seq: 1 })) + expect(remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 's', title: 't' }) + }) + + test.fails('disposes its client even when the test assertion fails', async ({ start }) => { + expired.push(start) + clients.push(await start()) + expect.fail('fixture cleanup negative control') + }) + + test('does not boot an unused start fixture', ({ mock, start }) => { + expired.push(start) + expect(mock.log.streams()).toEqual([]) + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) + }) + + test('owns a fresh mock when only the mock fixture is requested', ({ mock }) => { + expect(mock.log.calls()).toEqual([]) + expect(mock.log.streams()).toEqual([]) + expect(mock.endpoints()).not.toContain('session/rename') + }) +}) + +const missingConnection = createClientTest({ roster: webApp.pick(['@deepseek-ai/dsh-typert-registry']) }) +missingConnection('keeps a rejected startup with its caller and releases its globals', async ({ start }) => { + expired.push(start) + await expect(start()).rejects.toThrow('provides no `connection` service') + expect('__DSH_TRANSPORT__' in globalThis).toBe(false) +}) diff --git a/packages/test-support/client-runtime/tests/remote.client.spec.ts b/packages/test-support/client-runtime/tests/remote.client.spec.ts index bb297bb0b6..d7bbec5716 100644 --- a/packages/test-support/client-runtime/tests/remote.client.spec.ts +++ b/packages/test-support/client-runtime/tests/remote.client.spec.ts @@ -6,7 +6,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { TestRemote } from '../src/remote.ts' -import { scriptedSettingsRemote } from '../src/settings-remote.ts' describe('TestRemote', () => { it('delivers a forwarded event to its subscribers and stops after disposal', async () => { @@ -60,39 +59,3 @@ describe('TestRemote', () => { await ctx.fiber.dispose() }) }) - -describe('scriptedSettingsRemote', () => { - it('serves, writes, and replaces its scripted namespace list', async () => { - const first = { ns: 'first', revision: 1 } - const second = { ns: 'second', revision: 2 } - const remote = scriptedSettingsRemote([first]) - - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: true, hasDocument: false, namespaces: [first] }, - }) - await expect(remote.settings.update('first', {}, undefined)).resolves.toEqual({ ok: true, value: first }) - await expect(remote.settings.replace('missing', {}, undefined)).resolves.toMatchObject({ - ok: false, - error: { code: 'settings/rejected', details: { ns: 'missing' } }, - }) - await expect(remote.settings.mutate('first', [], undefined)).resolves.toEqual({ ok: true, value: first }) - expect(remote.update).toHaveBeenCalledWith('first', {}, undefined) - expect(remote.replace).toHaveBeenCalledWith('missing', {}, undefined) - expect(remote.mutate).toHaveBeenCalledWith('first', [], undefined) - - remote.publish([second]) - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: true, hasDocument: false, namespaces: [second] }, - }) - }) - - it('reports explicit deployment facts', async () => { - const remote = scriptedSettingsRemote([], { writable: false, hasDocument: true }) - await expect(remote.settings.describe()).resolves.toEqual({ - ok: true, - value: { writable: false, hasDocument: true, namespaces: [] }, - }) - }) -}) diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index bc8fbf0688..d73de457a3 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": ["client-build-environment", "node"] }, "include": [ "src" @@ -11,6 +12,15 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../api/gateway/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, @@ -55,6 +65,21 @@ }, { "path": "../../subagent/subagent" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../client/modules" + }, + { + "path": "../../client/web" + }, + { + "path": "../remote-mock" + }, + { + "path": "../../client/hmr" } ] } diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index ab000270b4..7a238460fb 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 27e9dbb497..d3e4e4540e 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 51d3e84d81..7958561a39 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts b/packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts index d617b98794..5afc35a44d 100644 --- a/packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts +++ b/packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts @@ -1,13 +1,15 @@ #!/usr/bin/env node /** Snapshot-only Loader driver: stream one fixture turn as canonical JSONL. */ -import type { Context } from '@deepseek-ai/cordis' +import type { Context, FiberState } from '@deepseek-ai/cordis' import { installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bootProductionProfile } from './production-profile.ts' const NAME = 'headless-test-driver' +const REQUIRED_ENTRY_ENV = 'DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID' +const FIBER_ACTIVE = 2 as FiberState.ACTIVE const [configPath, ...taskParts] = process.argv.slice(2) if (configPath === undefined || taskParts.length === 0 || taskParts.every(part => part.trim() === '')) { throw new Error(`${NAME}: expected `) @@ -22,6 +24,16 @@ try { profile: 'headless', overlayPaths: [resolveConfigPath(configPath, undefined)], }) + const requiredEntryId = process.env[REQUIRED_ENTRY_ENV] + if (requiredEntryId !== undefined) { + const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === requiredEntryId) + if (entry === undefined) throw new Error(`${NAME}: required fixture entry not found: ${requiredEntryId}`) + if (entry.fiber === undefined) throw new Error(`${NAME}: required fixture entry failed to import: ${requiredEntryId}`) + await entry.fiber.await() + if (entry.fiber.state !== FIBER_ACTIVE) { + throw new Error(`${NAME}: required fixture entry did not activate: ${requiredEntryId}`) + } + } const result = await runFixtureTurn(ctx, { task: taskParts.join(' '), onEvent: (sessionId: string, event: SessionEvent) => { diff --git a/packages/test-support/remote-mock/README.i18n.yaml b/packages/test-support/remote-mock/README.i18n.yaml new file mode 100644 index 0000000000..66722a563c --- /dev/null +++ b/packages/test-support/remote-mock/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/test-support/remote-mock/README.md +README.md: c3182b61a67aa6af799fb4fbac85083fbe805e59 +README.zh.md: 8199bc748d96df34de28d225db1289d76be6eed2 diff --git a/packages/test-support/remote-mock/README.md b/packages/test-support/remote-mock/README.md new file mode 100644 index 0000000000..c3182b61a6 --- /dev/null +++ b/packages/test-support/remote-mock/README.md @@ -0,0 +1,146 @@ +--- +description: "Endpoint-named mock for Typert Remote traffic: a table of unary answers and stream scripts, live stream control, a log, and the Connection carrier face, for test authors booting a real browser client without a Host." +kind: "package-library" +--- + +# @deepseek-ai/dsh-remote-mock + +English | [中文](README.zh.md) + +## Summary + +`dsh-remote-mock` lets tests configure Host responses through `mock.remote..` using native Vitest mock methods. The same functions answer direct calls and real Connection traffic; reusable tables supply default responses, and explicitly declared streams support test-driven frames and cancellation. Missing responses fail the call and are reported again by `assertNoUnmatched()` at teardown. The package runs without a business Host in Node or a browser page, imports no DOM, React, or Node modules, and is consumed from `devDependencies` only. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +### When to use it + +Use it when a spec boots real client plugins that talk to `ctx.remote` and wants to script the Host side by endpoint name: whole-client jsdom specs through `__DSH_TRANSPORT__`, and unit specs that call `dispatch` / `open` directly. Endpoints are the Gateway's wire names (`session/page`, `settings/describe`); `args` is the caller's positional argument list with a trailing `AbortSignal` removed; a value is whatever the test registers and is answered unchanged. The only declaration is whether an endpoint is unary (`unary`) or a stream (`stream`). + + +### Use the Remote proxy + +`mock.remote` exposes every namespace and method without a method list or a domain-specific helper. Each accessed endpoint uses a cached native Vitest mock; `@vitest/spy.fn` is the implementation behind `vi.fn` and also works in a browser page without the Vitest runner. The same mock answers direct calls and Connection traffic, so return overrides and call assertions observe the functions the client actually invokes: + +```text +const mock = RemoteMock.create().load(remoteDefaultResponses) +mock.remote.settings.describe.mockResolvedValue(ok({ + writable: true, hasDocument: false, namespaces: [], +})) +mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace)) +// After the client writes: +expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision) +``` + +Use `mockResolvedValue` for a persistent response, `mockResolvedValueOnce` or `mockReturnValueOnce` for queued responses, and `mockImplementation` for argument-dependent behavior. Native queued responses run in registration order, then the mock's current implementation answers. Its initial implementation reads the registered default. `mockClear()` preserves responses and queues; `mockReset()` clears overrides and restores that initial implementation, which reads the latest default. Missing defaults still fail after queued responses are exhausted, including direct calls, which can throw synchronously. + +Only explicit `stream()` or table stream declarations select stream methods; everything else uses unary mocks. Accessing a method does not fabricate a successful business result. Declare stream mode before retaining a method reference: each endpoint/mode owns its own mock. Namespace and method `then` probes and symbol reads are inert. + +`MockedRemote` uses Vitest's deep mock transformation over the entire generated `TypertRemoteNamespaceMap`. A non-empty map preserves its namespace and method names, parameters, results, and native spy types. An empty map makes only this test proxy `any`, allowing arbitrary namespaces and methods; it does not augment or weaken production Remote declarations. No copied method signatures, optional generated-module suppressions, or compiler-wide flags are needed. Before handing off Remote/mock changes, run `pnpm run typecheck` to generate and check the real Client types; missing, stale, or partial declarations require rebuilding first. Unbuilt test success or `any` inference is not strict type evidence. + +### Register default responses + +`load(table)` installs reusable `unary` values or handlers, `stream` scripts, and scriptless `streams` declarations. `unary(endpoint, value)` and `unary(endpoint, fn)` register individual defaults; handlers receive the caller's positional arguments and use existing request types. Each endpoint holds only its latest default, including an explicit `undefined`; updating defaults does not discard native overrides. `ok(value)` builds `{ ok: true, value }`; failures use `{ ok: false, error: { code, message, details } }`. Keep stateful handlers, promises, and native queues local to each test: + +```text +const initial = { writable: true, hasDocument: false, namespaces: [] } +const mock = RemoteMock.create().load({ + unary: { 'settings/describe': ok(initial) }, +}) +mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true })) +``` + +### Drive streams + +A stream script is a function of the open `args` and a `StreamHandle` (`push`, `end`, `fail(error)`, `signal`); the stream stays open after the script returns until the handle ends or fails it. `frames(items)` builds a script that yields the items and ends; `openStream(initial)` one that yields them and stays open. `mock.streams` controls streams the client currently holds open, filtered by the args they were opened with; `opened(endpoint, count)` resolves once the endpoint has been opened that many times, and `drained(endpoint)` once the consumer of every matching stream has pulled everything pushed so far, an open stream's consumer waiting for more and a settled stream's queue empty (pulled from the queue; that equals processed only for a consumer that handles items inside its read loop): + +```text +mock.stream('session/follow', openStream([snapshotFrame])) +await mock.streams.opened('session/follow', 1) +mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID) +mock.streams.fail('session/follow', new Error('gone')) +await mock.streams.drained('session/follow') +``` + +A failed stream rejects the consumer's next read with the given `Error`. Consumer cancellation (the opening signal or an early iterator `return()`) aborts `StreamHandle.signal`, ends the iteration without throwing, and logs the stream as `cancelled`. + +### Connect a client + +`mock.rpc` is the `ClientConnectionRpc` face: install it as `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }` and the production `connection` plugin uses it in place of the HTTP caller, so every Remote call reaches `dispatch` and every stream `open` with no envelopes in between. Payloads carry `{ args }` as the whole-client proxies send them (an array) or as the Gateway's own endpoints send them (one object, delivered as one positional arg); a call whose signal aborts rejects with the abort reason. `RemoteMock.create()` registers one stream, `$events`, that answers the Gateway client's opening with `{ type: 'ready', clientId, host: { home } }` (host from `RemoteMockOptions.host`, default `/home/mock`) and stays open, which is what lets the assembled client reach `connected`; a spec overrides or fails it like any other stream. + +### Observe and assert + +`mock.log.calls(endpoint?)` lists unary calls through `dispatch` or `rpc.call` (`args`, `seq`, a live `state` of `pending` / `answered` / `failed`, and the answer or thrown error as `result`), `streams(endpoint?)` lists scripted opens with their live `state` and `pushed` count, `requests(endpoint?)` lists the first positional arg of calls and opens in order (without an endpoint, excluding the Gateway's own `$`-prefixed endpoints), and `unmatched()` lists requests that found no rule. Native `.mock.calls` additionally includes direct proxy calls; carrier stream mocks receive their final cancellation signal. `assertNoUnmatched()` reports misses at teardown. `modeOf(endpoint)` reports explicit registrations; `endpoints()` also includes accessed proxy methods so assembly can provide their namespaces. + +### What can go wrong + +- **A request has no rule** — `dispatch` rejects and `open` throws `remote-mock: no rule for ; registered: …`, and the log records the miss; register the endpoint. +- **A payload is not `{ args: unknown[] | object }`** — `rpc.call` rejects and `rpc.open` throws a `TypeError`; the whole-client proxies send the array form and the Gateway's own endpoints the object form, so a hand-written call is at fault. +- **A second concurrent read on one stream** — the read rejects; the Gateway reads streams sequentially, so this names a test-side misuse. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +### Design + +`dispatch` and `open` take the endpoint and positional args; `rpc` exposes the same core through Connection's decoded carrier interface. Each mock owns its native functions and queued overrides; shared tables supply defaults without copying handlers or answer objects. Each scripted stream owns its queue, its single pending read, and its log entry; the first of `end`, `fail`, or consumer cancellation settles it. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Public face re-exports | +| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`: default responses, native mocks, Connection dispatch, controlled streams and missing-response checks; `ok` | +| [`src/remote-proxy.ts`](src/remote-proxy.ts) | Namespace/method lookup and generated-map mock types | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` scripts and `MockStream` (handle + `AsyncIterable`) | +| [`src/log.ts`](src/log.ts) | Log store with the shared `seq` counter | +| — | No runtime invariant companion is published; this test-support library owns no production event stream or mutable process state, and its behavior is exercised by its package tests. | + +
+ +----- + + +## Model Experience + +None, as this package is browser-side test infrastructure; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- **In-process carrier only** — `rpc` serves a client in the same realm through `__DSH_TRANSPORT__.rpc`; no HTTP or WebSocket carrier for browser-lane specs is provided. +- **Values cross by reference** — answers and stream items reach the client unserialized, so a non-JSON value that the real wire would reject passes through unchanged. +- **Values are not checked** — a unary answer must be the result the caller reads (`{ ok, value }` or `{ ok: false, error }`); the mock passes it through unchanged and does not check those fields. +- **No payload matching** — rules match on endpoint only; discriminate on business arguments inside a handler. +- **Native stream overrides own their iterable** — an override returning its own iterable bypasses scripted-stream logs, `requests`, `opened`, `drained`, and `push` / `end` / `fail`; the caller also owns cancellation. Native call assertions still work. Use a registered stream script when a scenario needs those controls. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/test-support/remote-mock/README.zh.md b/packages/test-support/remote-mock/README.zh.md new file mode 100644 index 0000000000..8199bc748d --- /dev/null +++ b/packages/test-support/remote-mock/README.zh.md @@ -0,0 +1,146 @@ +--- +description: "Typert Remote 流量的端点具名 mock:一元应答与流脚本的表、活流控制、日志与 Connection 载体面,供测试作者在没有 Host 的情况下启动真实浏览器客户端。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-remote-mock + +[English](README.md) | 中文 + +## 概述 + +`dsh-remote-mock` 让测试通过 `mock.remote..`,使用原生 Vitest mock 方法配置 Host 响应。同一组函数应答直接调用与真实 Connection 流量;可复用的表提供默认响应,显式声明的流支持测试驱动的推帧与取消。缺少响应时调用失败,`assertNoUnmatched()` 会在收尾时再次报告。本包无需业务 Host 即可在 Node 或浏览器页面中运行,不导入 DOM、React 或 Node 模块,只从 `devDependencies` 消费。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +### 何时使用 + +当测试要启动与 `ctx.remote` 对话的真实客户端插件、并想按端点名脚本化 Host 侧时使用它:经 `__DSH_TRANSPORT__` 的整体 jsdom 测试,以及直接调用 `dispatch` / `open` 的单元测试。端点是 Gateway 的 wire 名(`session/page`、`settings/describe`);`args` 是调用方的位置参数列表,末尾的 `AbortSignal` 已剥掉;值就是测试登记的东西,原样应答。唯一的声明是端点是一元(`unary`)还是流(`stream`)。 + + +### 使用 Remote Proxy + +`mock.remote` 无需方法清单或领域专属 Helper 即可提供每个命名空间和方法。每个访问过的端点使用缓存的原生 Vitest mock;`@vitest/spy.fn` 就是 `vi.fn` 背后的实现,也能在没有 Vitest runner 的浏览器页面中运行。同一个 mock 应答直接调用与 Connection 流量,因此返回值覆盖和调用断言观察的是客户端实际调用的函数: + +```text +const mock = RemoteMock.create().load(remoteDefaultResponses) +mock.remote.settings.describe.mockResolvedValue(ok({ + writable: true, hasDocument: false, namespaces: [], +})) +mock.remote.settings.mutate.mockResolvedValueOnce(ok(updatedNamespace)) +// After the client writes: +expect(mock.remote.settings.mutate).toHaveBeenCalledWith('locale', operations, revision) +``` + +使用 `mockResolvedValue` 设置持续响应,使用 `mockResolvedValueOnce` 或 `mockReturnValueOnce` 排队设置响应,使用 `mockImplementation` 按参数决定行为。原生队列按登记顺序消费响应,耗尽后由 mock 的当前实现应答;初始实现读取已登记的默认响应。`mockClear()` 保留响应与队列;`mockReset()` 清除覆盖并恢复初始实现,由它读取最新默认响应。若未配置默认响应,排队响应耗尽后仍会失败,包括可能同步抛错的直接调用。 + +只有显式 `stream()` 或响应表中的流声明才选择流方法;其余均使用一元 mock。访问方法不会凭空构造成功的业务结果。保存方法引用前先声明流模式:每个端点/模式拥有各自的 mock。命名空间和方法的 `then` 探测及 symbol 读取均无副作用。 + +`MockedRemote` 使用 Vitest 的深层 mock 类型转换,覆盖完整生成的 `TypertRemoteNamespaceMap`。非空映射保留命名空间与方法名、参数、返回值和原生 spy 类型。映射为空时只有这个测试 Proxy 变成 `any`,允许任意命名空间和方法;它不增补或弱化生产 Remote 声明。不需要复制方法签名、抑制可选生成模块错误或开启编译器级全局 Flag。交付 Remote/mock 改动前运行 `pnpm run typecheck`,生成并检查真实 Client 类型;声明缺失、陈旧或不完整时先重新构建。无构建测试通过或推断为 `any` 都不是严格类型证据。 + +### 登记默认响应 + +`load(table)` 安装可复用的 `unary` 值或 handler、`stream` 脚本以及无脚本的 `streams` 声明。`unary(endpoint, value)` 与 `unary(endpoint, fn)` 登记单个默认响应;handler 接收调用方的位置参数,并使用现有请求类型。每个端点仅保存最新默认响应,包括显式 `undefined`;更新默认响应不会清除原生覆盖。`ok(value)` 构造 `{ ok: true, value }`;失败使用 `{ ok: false, error: { code, message, details } }`。有状态 handler、promise 与原生队列均由各测试独立持有: + +```text +const initial = { writable: true, hasDocument: false, namespaces: [] } +const mock = RemoteMock.create().load({ + unary: { 'settings/describe': ok(initial) }, +}) +mock.remote.settings.describe.mockResolvedValueOnce(ok({ ...initial, hasDocument: true })) +``` + +### 驾驭流 + +流脚本是一个接收打开时的 `args` 与 `StreamHandle`(`push`、`end`、`fail(error)`、`signal`)的函数;脚本返回后流保持打开,直到句柄结束或失败。`frames(items)` 构造吐完即结束的脚本,`openStream(initial)` 构造吐完后保持打开的脚本。`mock.streams` 控制客户端当前打开着的流,可按打开时的参数过滤;`opened(endpoint, count)` 在该端点被打开达到该次数时 resolve,`drained(endpoint)` 在每条匹配流的消费方都拉完了迄今推入的全部内容时 resolve——打开的流要其消费方再次等待,已定局的流要队列已空(拉完指从队列取走;只有在读循环内处理项的消费方才等于处理完): + +```text +mock.stream('session/follow', openStream([snapshotFrame])) +await mock.streams.opened('session/follow', 1) +mock.streams.push('session/follow', eventFrame, ([request]) => (request as { sessionId: string }).sessionId === SID) +mock.streams.fail('session/follow', new Error('gone')) +await mock.streams.drained('session/follow') +``` + +失败的流让消费方的下一次读取以给定的 `Error` reject。消费方取消(打开时的 signal 或 iterator 提前 `return()`)会中止 `StreamHandle.signal`、结束迭代而不抛错,并把该流记为 `cancelled`。 + +### 接上客户端 + +`mock.rpc` 是 `ClientConnectionRpc` 面:装成 `globalThis.__DSH_TRANSPORT__ = { rpc: mock.rpc }`,生产的 `connection` 插件就用它替代 HTTP 调用方,每次 Remote 调用直达 `dispatch`、每条流直达 `open`,中间没有信封。payload 携带 `{ args }`——整机代理发数组、Gateway 自身端点发一个对象(到达时是一个位置参数);signal 中止的调用以中止原因 reject。`RemoteMock.create()` 登记一条流 `$events`,用 `{ type: 'ready', clientId, host: { home } }`(host 来自 `RemoteMockOptions.host`,默认 `/home/mock`)应答 Gateway 客户端的打开并保持打开——这正是整机能达到 `connected` 的原因;测试可以像任何流一样覆盖或让它失败。 + +### 观察与断言 + +`mock.log.calls(endpoint?)` 列出经 `dispatch` 或 `rpc.call` 的一元调用(`args`、`seq`、实时 `state` 为 `pending` / `answered` / `failed`,以及作为 `result` 的应答值或抛出的错误),`streams(endpoint?)` 列出脚本流的打开记录及其实时 `state` 与 `pushed` 计数,`requests(endpoint?)` 按顺序列出调用与打开的首个位置参数(不带端点时去掉 Gateway 自己带 `$` 前缀的端点),`unmatched()` 列出没找到规则的请求。原生 `.mock.calls` 还包含直接 Proxy 调用;载体的流 mock 会收到末尾的取消信号。`assertNoUnmatched()` 在收尾时报告漏配。`modeOf(endpoint)` 报告显式登记;`endpoints()` 还包含访问过的 Proxy 方法,使装配能够提供它们的命名空间。 + +### 可能出什么问题 + +- **请求没有规则**——`dispatch` reject、`open` 抛出 `remote-mock: no rule for ; registered: …`,日志记下这次漏配;请登记该端点。 +- **payload 不是 `{ args: unknown[] | object }`**——`rpc.call` reject、`rpc.open` 抛 `TypeError`;整机代理发数组形式、Gateway 自身端点发对象形式,所以问题出在手写调用。 +- **同一条流上有第二个并发读取**——该读取 reject;Gateway 顺序读取流,因此这指向测试侧误用。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +### 设计 + +`dispatch` 与 `open` 接收端点与位置参数;`rpc` 通过 Connection 的已解码载体接口暴露同一个核心。每个 mock 独立持有原生函数及排队覆盖;共享表提供默认响应,不复制 handler 或应答对象。每条脚本流拥有自己的队列、唯一挂起读取和日志条目;`end`、`fail`、消费方取消三者中最先发生者定局。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 公开面转出 | +| [`src/remote-mock.ts`](src/remote-mock.ts) | `RemoteMock`:默认响应、原生 mock、Connection 分发、受控流与缺失响应检查;`ok` | +| [`src/remote-proxy.ts`](src/remote-proxy.ts) | 命名空间/方法查找与生成映射的 mock 类型 | +| [`src/streams.ts`](src/streams.ts) | `frames` / `openStream` 脚本与 `MockStream`(句柄 + `AsyncIterable`) | +| [`src/log.ts`](src/log.ts) | 带共享 `seq` 计数器的日志 | +| — | 不发布运行时不变量伴生件;本测试支持库不拥有任何生产事件流或可变进程状态,其行为由本包测试覆盖。 | + +
+ +----- + + +## 模型体验 + +无;本包是浏览器侧测试基础设施,无一物到达模型请求。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +- **仅进程内载体**——`rpc` 经 `__DSH_TRANSPORT__.rpc` 服务同一 realm 的客户端;不提供给浏览器车道测试用的 HTTP 或 WebSocket 载体。 +- **值按引用传递**——应答与流项都未经序列化就到达客户端,真实线路会拒绝的非 JSON 值在这里原样通过。 +- **不校验值**——一元应答必须是调用方读取的结果(`{ ok, value }` 或 `{ ok: false, error }`);mock 原样传递它,不检查这些字段。 +- **不做 payload 匹配**——规则只按端点匹配;在 handler 内按业务参数判别。 +- **原生流覆盖自行管理 iterable**——覆盖返回自有 iterable 时,不参与脚本流日志、`requests`、`opened`、`drained` 以及 `push` / `end` / `fail`;调用方也负责取消。原生调用断言仍然有效。需要这些控制能力的场景应使用已登记的流脚本。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/test-support/remote-mock/package.json b/packages/test-support/remote-mock/package.json new file mode 100644 index 0000000000..4304430075 --- /dev/null +++ b/packages/test-support/remote-mock/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-remote-mock", + "description": "Endpoint-named mock for Typert Remote traffic: unary answers and stream scripts per /, live stream control, a log, and the Connection carrier face whole-client specs install", + "version": "0.1.5-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/test-support/remote-mock" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^4.1.8" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-settings-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "typescript": "^6.0.3" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/test-support/remote-mock/src/index.ts b/packages/test-support/remote-mock/src/index.ts new file mode 100644 index 0000000000..9c78ee3a27 --- /dev/null +++ b/packages/test-support/remote-mock/src/index.ts @@ -0,0 +1,16 @@ +/** + * Endpoint-named mock for Typert Remote traffic: a table of unary answers and + * stream scripts keyed by `/`, scripted-stream control, a + * carrier log, and the Connection carrier face the `connection` plugin + * accepts through `__DSH_TRANSPORT__.rpc`. Values are whatever the test + * registers; the only declaration is whether an endpoint is unary or a + * stream. Browser-safe: no DOM, React, or Node imports, no runtime import + * from another harness package. + * @module @deepseek-ai/dsh-remote-mock + */ +export { RemoteMock, ok } from './remote-mock.ts' +export type { OpenStreams, RemoteMockOptions, RemoteTable, StreamFilter, UnaryRuleFn } from './remote-mock.ts' +export { frames, openStream } from './streams.ts' +export type { StreamHandle, StreamScript } from './streams.ts' +export type { LoggedCall, LoggedMiss, LoggedStream, MockLog } from './log.ts' +export type { MockedRemote } from './remote-proxy.ts' diff --git a/packages/test-support/remote-mock/src/log.ts b/packages/test-support/remote-mock/src/log.ts new file mode 100644 index 0000000000..723a11f67d --- /dev/null +++ b/packages/test-support/remote-mock/src/log.ts @@ -0,0 +1,131 @@ +/** What a `RemoteMock` saw: answered calls, opened streams, and requests without a rule. */ + +/** One unary call, logged when its rule is selected and settled when the rule does. */ +export interface LoggedCall { + readonly endpoint: string + /** Positional args as the caller passed them. */ + readonly args: readonly unknown[] + /** `pending` until the rule settles, then `answered` or `failed`. */ + readonly state: 'pending' | 'answered' | 'failed' + /** The answer once `answered`, the thrown error once `failed`. */ + readonly result: unknown + /** Global order across calls and stream opens. */ + readonly seq: number +} + +/** One stream open and its current state. */ +export interface LoggedStream { + readonly endpoint: string + /** Positional args the stream was opened with. */ + readonly args: readonly unknown[] + readonly state: 'open' | 'ended' | 'failed' | 'cancelled' + /** Items delivered to the consumer so far. */ + readonly pushed: number + /** Global order across calls and stream opens. */ + readonly seq: number +} + +/** One request that found no rule. */ +export interface LoggedMiss { + readonly endpoint: string + readonly mode: 'unary' | 'stream' +} + +/** Read side of the log. */ +export interface MockLog { + /** + * Unary calls, oldest first, with their live state. + * @param endpoint - restrict to one endpoint. + * @returns matching entries. + */ + calls(endpoint?: string): readonly LoggedCall[] + /** + * Stream opens, oldest first, with their live state. + * @param endpoint - restrict to one endpoint. + * @returns matching entries. + */ + streams(endpoint?: string): readonly LoggedStream[] + /** + * The first positional arg of every unary call and stream open, oldest first: the request object of the Gateway's + * one-parameter endpoints. Without `endpoint`, every endpoint the client asked the Host for, except the Gateway's + * own `$`-prefixed ones (`$events`). + * @param endpoint - restrict to one endpoint. + * @returns the requests. + */ + requests(endpoint?: string): readonly unknown[] + /** Requests that found no rule, oldest first. */ + unmatched(): readonly LoggedMiss[] +} + +/** Mutable call entry the dispatcher settles. */ +export interface CallRecord extends LoggedCall { + state: LoggedCall['state'] + result: unknown +} + +/** Mutable stream entry shared with the live stream. */ +export interface StreamRecord extends LoggedStream { + state: LoggedStream['state'] + pushed: number +} + +/** The mock's log store. */ +export class MockLogStore implements MockLog { + private readonly callEntries: CallRecord[] = [] + private readonly streamEntries: StreamRecord[] = [] + private readonly missEntries: LoggedMiss[] = [] + private seq = 0 + + calls(endpoint?: string): readonly LoggedCall[] { + return this.callEntries.filter(entry => endpoint === undefined || entry.endpoint === endpoint) + } + + streams(endpoint?: string): readonly LoggedStream[] { + return this.streamEntries.filter(entry => endpoint === undefined || entry.endpoint === endpoint) + } + + requests(endpoint?: string): readonly unknown[] { + const entries: (LoggedCall | LoggedStream)[] = [...this.calls(endpoint), ...this.streams(endpoint)] + return entries + .filter(entry => endpoint !== undefined || !entry.endpoint.startsWith('$')) + .sort((a, b) => a.seq - b.seq) + .map(entry => entry.args[0]) + } + + unmatched(): readonly LoggedMiss[] { + return [...this.missEntries] + } + + /** + * Record one call whose rule was selected. + * @param endpoint - endpoint. + * @param args - positional args. + * @returns the mutable entry the dispatcher settles. + */ + call(endpoint: string, args: readonly unknown[]): CallRecord { + const record: CallRecord = { endpoint, args, state: 'pending', result: undefined, seq: ++this.seq } + this.callEntries.push(record) + return record + } + + /** + * Record one stream open. + * @param endpoint - endpoint. + * @param args - positional args. + * @returns the mutable entry the stream updates. + */ + stream(endpoint: string, args: readonly unknown[]): StreamRecord { + const record: StreamRecord = { endpoint, args, state: 'open', pushed: 0, seq: ++this.seq } + this.streamEntries.push(record) + return record + } + + /** + * Record one request without a rule. + * @param endpoint - endpoint. + * @param mode - how it was requested. + */ + miss(endpoint: string, mode: 'unary' | 'stream'): void { + this.missEntries.push({ endpoint, mode }) + } +} diff --git a/packages/test-support/remote-mock/src/remote-mock.ts b/packages/test-support/remote-mock/src/remote-mock.ts new file mode 100644 index 0000000000..00d10668a3 --- /dev/null +++ b/packages/test-support/remote-mock/src/remote-mock.ts @@ -0,0 +1,385 @@ +/** `RemoteMock`: an endpoint table (unary answers or stream scripts), live stream control, a log, and the Connection carrier face. */ + +import type { ClientConnectionRpc, ConnectionRpcResult } from '@deepseek-ai/dsh-client-connection/client' +import { fn, type Mock } from '@vitest/spy' +import { MockLogStore, type MockLog } from './log.ts' +import { MockStream, toError, type StreamScript } from './streams.ts' +import { createRemoteProxy, type MockedRemote } from './remote-proxy.ts' + +/** A unary handler receives the caller's positional arguments, without its trailing `AbortSignal`. */ +export type UnaryRuleFn = (...args: Args) => Result + +/** Endpoint defaults: unary values or positional handlers, stream scripts, and stream declarations. */ +export interface RemoteTable { + readonly unary?: Readonly> + readonly stream?: Readonly> + /** Endpoints declared as streams without a script, so an open finds a stream miss rather than a unary one. */ + readonly streams?: readonly string[] +} + +/** Construction options. */ +export interface RemoteMockOptions { + /** Host facts the built-in `$events` ready frame carries; default `{ home: '/home/mock' }`. */ + readonly host?: { readonly home: string } +} + +/** Filter over the args a stream was opened with. */ +export type StreamFilter = (args: readonly unknown[]) => boolean + +/** Control and synchronization for streams opened by registered scripts; native overrides are excluded. */ +export interface OpenStreams { + /** + * Push one item into every open stream on `endpoint`, optionally filtered by its open args. + * @param endpoint - endpoint. + * @param item - item to deliver. + * @param where - filter over open args. + * @returns how many streams received it. + */ + push(endpoint: string, item: unknown, where?: StreamFilter): number + /** + * End every matching open stream. + * @param endpoint - endpoint. + * @param where - filter over open args. + * @returns how many streams ended. + */ + end(endpoint: string, where?: StreamFilter): number + /** + * Fail every matching open stream with `error`. + * @param endpoint - endpoint. + * @param error - error the consumer's read rejects with. + * @param where - filter over open args. + * @returns how many streams failed. + */ + fail(endpoint: string, error: Error, where?: StreamFilter): number + /** + * Resolve once registered scripts have opened `endpoint` at least `count` times in total. + * @param endpoint - endpoint. + * @param count - opens to wait for. + */ + opened(endpoint: string, count: number): Promise + /** + * Resolve once the consumer of every matching stream has pulled everything pushed so far and, for a stream still + * open, waits for more; a settled stream counts once its queue is empty, and a stream nobody reads never drains. + * Pulled means taken from the queue: a consumer that processes items asynchronously after pulling them may still + * be working on the last one. + * @param endpoint - endpoint. + * @param where - filter over open args. + */ + drained(endpoint: string, where?: StreamFilter): Promise +} + +const EVENTS_ENDPOINT = '$events' + +/** + * The success envelope the Client's Remote callers read: `{ ok: true, value }`. + * @param value - success value. + * @returns the envelope. + */ +export function ok(value: T): { readonly ok: true; readonly value: T } { + return { ok: true, value } +} + +interface OpenWaiter { + readonly endpoint: string + readonly count: number + resolve(): void +} + +/** A missing default answer is an unmatched request, not a selected rule that failed. */ +class MissingUnaryRule extends Error {} + +/** + * Endpoint-named Remote mock. `dispatch` / `open` are the core; `rpc` is the + * same core as the Connection carrier the `connection` plugin accepts through + * `__DSH_TRANSPORT__.rpc`. + */ +export class RemoteMock { + /** + * Create a mock whose `$events` stream answers the Gateway client's opening + * with one ready frame and then stays open, so the assembled client connects. + * @param options - host facts for the ready frame. + * @returns the mock. + */ + static create(options: RemoteMockOptions = {}): RemoteMock { + const mock = new RemoteMock() + const host = options.host ?? { home: '/home/mock' } + let generation = 0 + return mock.stream(EVENTS_ENDPOINT, (_args, stream) => { + generation += 1 + stream.push({ type: 'ready', clientId: `mock-client-${String(generation)}`, host }) + }) + } + + private readonly unaryDefaults = new Map() + private readonly scripts = new Map() + private live: MockStream[] = [] + private openWaiters: OpenWaiter[] = [] + private readonly logStore = new MockLogStore() + private readonly unaryMocks = new Map>() + private readonly streamMocks = new Map>() + private readonly proxy = createRemoteProxy(endpoint => this.modeOf(endpoint) === 'stream' + ? this.streamMock(endpoint) + : this.unaryMock(endpoint)) + + /** + * Native mock functions for every namespace: configure answers and inspect calls without declaring method names. + * Explicit stream registrations select stream mocks; all other methods use unary mocks. + * Native overrides belong to this instance; `mockReset()` restores the implementation reading current defaults. + * Generated declarations provide exact signatures; absent declarations weaken only this test-owned proxy. + */ + // oxlint-disable-next-line typescript/no-unsafe-assignment -- Only this test proxy becomes any without generated namespaces. + readonly remote: MockedRemote = this.proxy as MockedRemote + + /** Everything observed so far. */ + readonly log: MockLog = this.logStore + + /** Control over streams opened by registered scripts. */ + readonly streams: OpenStreams = { + push: (endpoint, item, where) => this.forEachOpen(endpoint, where, (stream) => { stream.push(item) }), + end: (endpoint, where) => this.forEachOpen(endpoint, where, (stream) => { stream.end() }), + fail: (endpoint, error, where) => this.forEachOpen(endpoint, where, (stream) => { stream.fail(error) }), + opened: (endpoint, count) => { + if (this.logStore.streams(endpoint).length >= count) return Promise.resolve() + return new Promise((resolve) => { this.openWaiters.push({ endpoint, count, resolve }) }) + }, + drained: (endpoint, where) => Promise.all(this.matching(endpoint, where).map(stream => stream.drained())).then(() => undefined), + } + + /** + * The Connection carrier face: `call` dispatches, `open` opens; payloads carry + * `{ args }` as the whole-client proxies (an array) or the Gateway's own + * endpoints (one object) send them, and a call aborted by its signal rejects. + */ + readonly rpc: ClientConnectionRpc = { + call: async (_channel, endpoint, payload, signal) => { + const pending = this.dispatch(endpoint, argsOf(endpoint, payload)) + const value = await (signal === undefined ? pending : settleOrAbort(pending, signal)) + // The carrier contract names the result envelope; the registered value is taken as that envelope unchecked. + return value as ConnectionRpcResult + }, + open: (_channel, endpoint, payload, signal) => this.open(endpoint, argsOf(endpoint, payload), signal), + } + + private constructor() {} + + /** + * Set the endpoint's default positional-argument handler without changing its native mock overrides. + * @param endpoint - `/`. + * @param rule - handler computing the answer. + * @returns this. + */ + unary(endpoint: string, rule: UnaryRuleFn): this + /** + * Set the endpoint's default answer, including explicit `undefined`, without changing its native mock overrides. + * @param endpoint - `/`. + * @param rule - default answer. + * @returns this. + */ + unary(endpoint: string, rule: unknown): this + unary(endpoint: string, rule: unknown): this { + this.unaryDefaults.set(endpoint, rule) + return this + } + + /** + * Declare `endpoint` as a stream and replace its default script when supplied. A declaration without a script + * preserves any existing script; an endpoint declared without one fails an open as a stream miss. + * @param endpoint - `/`. + * @param script - script (`frames` / `openStream` build the common ones). + * @returns this. + */ + stream(endpoint: string, script?: StreamScript): this { + if (script !== undefined || !this.scripts.has(endpoint)) this.scripts.set(endpoint, script) + return this + } + + /** + * Register endpoint defaults without changing native mock overrides. + * @param table - unary answers and stream scripts. + * @returns this. + */ + load(table: RemoteTable): this { + for (const [endpoint, rule] of Object.entries(table.unary ?? {})) this.unary(endpoint, rule) + for (const endpoint of table.streams ?? []) this.stream(endpoint) + for (const [endpoint, script] of Object.entries(table.stream ?? {})) this.stream(endpoint, script) + return this + } + + /** + * Whether `endpoint` is declared a stream (with or without a script) or has a unary rule — the one declaration + * the whole-client proxies need; an endpoint neither declared nor ruled is dispatched as a unary call. + * @param endpoint - endpoint. + * @returns the mode, or undefined when nothing is registered. + */ + modeOf(endpoint: string): 'unary' | 'stream' | undefined { + if (this.scripts.has(endpoint)) return 'stream' + if (this.unaryDefaults.has(endpoint)) return 'unary' + return undefined + } + + /** + * Registered endpoints and accessed proxy methods, sorted. + * @returns endpoint names. + */ + endpoints(): readonly string[] { + const names = new Set(this.scripts.keys()) + for (const endpoint of this.unaryDefaults.keys()) names.add(endpoint) + for (const [namespace, methods] of Object.entries(this.proxy)) { + for (const method of Object.keys(methods)) names.add(`${namespace}/${method}`) + } + return [...names].sort() + } + + /** + * Answer one unary call with the registered rule's value, verbatim. The call + * is logged as soon as its rule is selected and settles with the rule: a + * rule that throws or rejects fails the call with that error. + * @param endpoint - endpoint. + * @param args - positional args. + * @returns the answer. + * @throws {Error} when no rule is registered (logged as unmatched). + */ + async dispatch(endpoint: string, args: readonly unknown[]): Promise { + let answer: unknown + try { + answer = this.unaryMock(endpoint)(...args) + } catch (error) { + if (error instanceof MissingUnaryRule) throw error + answer = Promise.reject(toError(error)) + } + const record = this.logStore.call(endpoint, args) + try { + record.result = await answer + } catch (error) { + record.state = 'failed' + record.result = error + throw error + } + record.state = 'answered' + return record.result + } + + private unaryMock(endpoint: string): Mock { + let mock = this.unaryMocks.get(endpoint) + if (mock === undefined) { + mock = fn((...args: readonly unknown[]) => Promise.resolve(this.unaryAnswer(endpoint, args))) + this.unaryMocks.set(endpoint, mock) + } + return mock + } + + private unaryAnswer(endpoint: string, args: readonly unknown[]): unknown { + if (!this.unaryDefaults.has(endpoint)) { + this.logStore.miss(endpoint, 'unary') + throw new MissingUnaryRule(this.noRuleMessage(endpoint)) + } + const rule = this.unaryDefaults.get(endpoint) + return isRuleFn(rule) ? answerOf(rule, args) : rule + } + + /** + * Open through the endpoint's native mock; its default runs the registered script as a controlled stream. + * A native override returns its own iterable: the caller owns consumption and cancellation, outside `OpenStreams`. + * @param endpoint - endpoint. + * @param args - positional args. + * @param signal - consumer cancellation. + * @returns the controlled script stream or the native override's caller-owned iterable. + * @throws {Error} when the default runs without a registered script (logged as unmatched). + */ + open(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { + return this.streamMock(endpoint)(...args, signal) as AsyncIterable + } + + private streamMock(endpoint: string): Mock { + let mock = this.streamMocks.get(endpoint) + if (mock === undefined) { + mock = fn((...values: readonly unknown[]) => { + const args = [...values] + const signal = args.at(-1) instanceof AbortSignal ? args.pop() as AbortSignal : new AbortController().signal + return this.openScript(endpoint, args, signal) + }) + this.streamMocks.set(endpoint, mock) + } + return mock + } + + private openScript(endpoint: string, args: readonly unknown[], signal: AbortSignal): AsyncIterable { + const script = this.scripts.get(endpoint) + if (script === undefined) { + this.logStore.miss(endpoint, 'stream') + throw new Error(this.noRuleMessage(endpoint)) + } + const stream = new MockStream(this.logStore.stream(endpoint, args), signal) + this.live.push(stream) + this.wakeOpened(endpoint) + stream.run(script, args) + return stream + } + + /** Throw when any request found no rule, naming the endpoints and the registered ones. */ + assertNoUnmatched(): void { + const unmatched = this.logStore.unmatched() + if (unmatched.length === 0) return + const lines = unmatched.map(entry => ` ${entry.endpoint} (${entry.mode})`) + throw new Error(`remote-mock: ${String(unmatched.length)} unmatched request(s):\n${lines.join('\n')}\nregistered: ${this.endpoints().join(', ')}`) + } + + private noRuleMessage(endpoint: string): string { + return `remote-mock: no rule for ${endpoint}; registered: ${this.endpoints().join(', ')}` + } + + /** Streams on `endpoint` still open or still holding items their consumer has not pulled; the rest are forgotten. */ + private matching(endpoint: string, where: StreamFilter | undefined): MockStream[] { + this.live = this.live.filter(stream => stream.record.state === 'open' || stream.queued > 0) + return this.live.filter(stream => stream.record.endpoint === endpoint && (where === undefined || where(stream.record.args))) + } + + private forEachOpen(endpoint: string, where: StreamFilter | undefined, action: (stream: MockStream) => void): number { + const targets = this.matching(endpoint, where).filter(stream => stream.record.state === 'open') + for (const stream of targets) action(stream) + return targets.length + } + + private wakeOpened(endpoint: string): void { + const opened = this.logStore.streams(endpoint).length + const ready = this.openWaiters.filter(waiter => waiter.endpoint === endpoint && opened >= waiter.count) + this.openWaiters = this.openWaiters.filter(waiter => !ready.includes(waiter)) + for (const waiter of ready) waiter.resolve() + } +} + +function isRuleFn(rule: unknown): rule is UnaryRuleFn { + return typeof rule === 'function' +} + +/** A rule's synchronous throw becomes a rejection so the call settles through one path. */ +function answerOf(rule: UnaryRuleFn, args: readonly unknown[]): unknown { + try { + return rule(...args) + } catch (error) { + return Promise.reject(toError(error)) + } +} + +/** Positional args from a carrier payload: the array the whole-client proxies send, or the one object the Gateway's own endpoints send. */ +function argsOf(endpoint: string, payload: unknown): readonly unknown[] { + if (typeof payload === 'object' && payload !== null && 'args' in payload) { + const { args } = payload + if (Array.isArray(args)) return args + if (typeof args === 'object' && args !== null) return [args] + } + throw new TypeError(`remote-mock: payload of ${endpoint} must be { args: unknown[] | object }`) +} + +/** + * Settle with the call, or reject with the abort reason first; the call itself + * always keeps a handler so its own outcome is never an unhandled rejection. + */ +function settleOrAbort(pending: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = (): void => { reject(signal.reason instanceof Error ? signal.reason : new Error('remote-mock: call aborted', { cause: signal.reason })) } + signal.addEventListener('abort', abort, { once: true }) + pending.then(resolve, reject).finally(() => { signal.removeEventListener('abort', abort) }) + if (signal.aborted) abort() + }) +} diff --git a/packages/test-support/remote-mock/src/remote-proxy.ts b/packages/test-support/remote-mock/src/remote-proxy.ts new file mode 100644 index 0000000000..c3abe542a0 --- /dev/null +++ b/packages/test-support/remote-mock/src/remote-proxy.ts @@ -0,0 +1,29 @@ +/** Lazy namespace access for native Remote mocks; production Remote declarations stay unchanged. */ +import type { MaybeMockedDeep } from '@vitest/spy' +import type { TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol' + +/** + * Native mocks for every generated Remote namespace, or an unrestricted local proxy without generated types. + * @template Api - generated namespace map available in the caller's TypeScript program. + */ +// oxlint-disable-next-line typescript/no-explicit-any -- An unbuilt namespace map has neither method names nor signatures. +export type MockedRemote = keyof Api extends never ? any : MaybeMockedDeep + +/** + * Cache accessed namespaces and expose the current native mock for each method. + * @param method - selects a stable endpoint mock for its configured invocation mode. + * @returns enumerable accessed namespaces and methods; symbols and thenable probes remain inert. + */ +export function createRemoteProxy(method: (endpoint: string) => unknown): Record { + return new Proxy(Object.create(null) as Record, { + get(namespaces, namespace) { + if (typeof namespace !== 'string' || namespace === 'then') return undefined + return namespaces[namespace] ??= new Proxy(Object.create(null) as Record, { + get(methods, name) { + if (typeof name !== 'string' || name === 'then') return undefined + return methods[name] = method(`${namespace}/${name}`) + }, + }) + }, + }) +} diff --git a/packages/test-support/remote-mock/src/streams.ts b/packages/test-support/remote-mock/src/streams.ts new file mode 100644 index 0000000000..0090087720 --- /dev/null +++ b/packages/test-support/remote-mock/src/streams.ts @@ -0,0 +1,196 @@ +/** Stream scripts and the pushable, abort-aware stream a script drives. */ + +import type { StreamRecord } from './log.ts' + +/** Test-side controls over one open stream. */ +export interface StreamHandle { + /** Queue one item for the consumer. */ + push(item: unknown): void + /** End the stream after the queued items drain. */ + end(): void + /** Fail the consumer's next read with `error` after the queued items drain. */ + fail(error: Error): void + /** Aborts when the opening signal aborts or the consumer returns early. */ + readonly signal: AbortSignal +} + +/** + * How a stream endpoint answers an open: receives the open args and the + * handle; the stream stays open after the script returns until `end()` or + * `fail()`. A script that throws or rejects fails the stream with that error. + */ +export type StreamScript = (args: readonly unknown[], stream: StreamHandle) => void | Promise + +/** + * Script yielding `items`, then ending. + * @param items - items in order. + * @returns the script. + */ +export function frames(items: readonly unknown[]): StreamScript { + return (_args, stream) => { + for (const item of items) stream.push(item) + stream.end() + } +} + +/** + * Script yielding `initial`, then staying open for `streams.push`. + * @param initial - items yielded on open. + * @returns the script. + */ +export function openStream(initial: readonly unknown[] = []): StreamScript { + return (_args, stream) => { + for (const item of initial) stream.push(item) + } +} + +type Settled = { readonly kind: 'end' } | { readonly kind: 'fail'; readonly error: Error } + +/** One open stream: a queue the script pushes into and a single consumer reads from; the log entry tracks its state. */ +export class MockStream implements StreamHandle, AsyncIterable { + private readonly queue: unknown[] = [] + private settled: Settled | undefined + private waiting: { readonly resolve: (result: IteratorResult) => void; readonly reject: (error: unknown) => void } | undefined + private drainWaiters: (() => void)[] = [] + private readonly cancellation = new AbortController() + readonly signal = this.cancellation.signal + + /** Consumer cancellation listener; attached while the stream is open, removed when it settles or cancels. */ + private readonly onAbort = (): void => { this.cancel(this.sourceSignal.reason) } + + /** Items pushed but not yet pulled by the consumer. */ + get queued(): number { + return this.queue.length + } + + /** + * @param record - log entry this stream updates. + * @param sourceSignal - cancellation from the caller that opened the stream. + */ + constructor(readonly record: StreamRecord, private readonly sourceSignal: AbortSignal) { + if (sourceSignal.aborted) this.cancel(sourceSignal.reason) + else sourceSignal.addEventListener('abort', this.onAbort, { once: true }) + } + + push(item: unknown): void { + if (this.record.state !== 'open') return + this.record.pushed += 1 + if (this.waiting !== undefined) { + const { resolve } = this.waiting + this.waiting = undefined + resolve({ value: item, done: false }) + return + } + this.queue.push(item) + } + + end(): void { + this.settle({ kind: 'end' }, 'ended') + } + + fail(error: Error): void { + this.settle({ kind: 'fail', error }, 'failed') + } + + /** + * Start `script` on this stream. + * @param script - the registered script. + * @param args - open args. + */ + run(script: StreamScript, args: readonly unknown[]): void { + let outcome: void | Promise + try { + outcome = script(args, this) + } catch (error) { + this.fail(toError(error)) + return + } + if (outcome instanceof Promise) void outcome.catch((error: unknown) => { this.fail(toError(error)) }) + } + + /** + * Resolve once the consumer has pulled every queued item and waits for the next one, or the stream is no longer + * open and holds nothing the consumer could still pull (a consumer that returns discards what it left). + * @returns settles when drained. + */ + drained(): Promise { + if (this.isDrained()) return Promise.resolve() + return new Promise((resolve) => { this.drainWaiters.push(resolve) }) + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => this.next(), + return: () => { + this.cancel() + return Promise.resolve({ value: undefined, done: true }) + }, + } + } + + private next(): Promise> { + const result = this.pull() + this.wakeDrained() + return result + } + + private pull(): Promise> { + if (this.queue.length > 0) return Promise.resolve({ value: this.queue.shift(), done: false }) + if (this.settled !== undefined) return this.finish(this.settled) + if (this.record.state === 'cancelled') return Promise.resolve({ value: undefined, done: true }) + if (this.waiting !== undefined) return Promise.reject(new Error(`remote-mock: ${this.record.endpoint} stream has one consumer`)) + return new Promise((resolve, reject) => { this.waiting = { resolve, reject } }) + } + + private finish(settled: Settled): Promise> { + return settled.kind === 'end' ? Promise.resolve({ value: undefined, done: true }) : Promise.reject(settled.error) + } + + private settle(settled: Settled, state: 'ended' | 'failed'): void { + if (this.record.state !== 'open') return + this.record.state = state + this.settled = settled + this.sourceSignal.removeEventListener('abort', this.onAbort) + this.wakeDrained() + // A pending read exists only while the queue is empty: push() resolves it directly instead of queueing. + const waiting = this.waiting + if (waiting === undefined) return + this.waiting = undefined + void this.finish(settled).then(waiting.resolve, waiting.reject) + } + + private cancel(reason?: unknown): void { + if (this.record.state === 'open') { + this.record.state = 'cancelled' + this.sourceSignal.removeEventListener('abort', this.onAbort) + const waiting = this.waiting + this.waiting = undefined + this.queue.length = 0 + this.cancellation.abort(reason) + waiting?.resolve({ value: undefined, done: true }) + } + // Whether cancelled or left after the producer settled, the consumer pulls nothing more: drop what it left. + this.queue.length = 0 + this.wakeDrained() + } + + private isDrained(): boolean { + return this.queue.length === 0 && (this.waiting !== undefined || this.record.state !== 'open') + } + + private wakeDrained(): void { + if (!this.isDrained()) return + const waiters = this.drainWaiters + this.drainWaiters = [] + for (const resolve of waiters) resolve() + } +} + +/** + * The `Error` a thrown value stands for: itself, or a new Error carrying its string form. + * @param reason - thrown value. + * @returns the error. + */ +export function toError(reason: unknown): Error { + return reason instanceof Error ? reason : new Error(String(reason)) +} diff --git a/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts new file mode 100644 index 0000000000..b1880d313f --- /dev/null +++ b/packages/test-support/remote-mock/tests/proxy-types.client.spec.ts @@ -0,0 +1,184 @@ +/** Mock-local unbuilt typing and native deep mocks over generated Remote declarations. */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' +import type { MaybeMockedDeep } from '@vitest/spy' +import { describe, expect, expectTypeOf, it } from 'vitest' +import type { TypertRemoteNamespace, TypertRemoteNamespaceMap } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-settings-controller/remote' +import type { RemoteMock } from '../src/index.ts' + +const root = resolve(import.meta.dirname, '../../../..') +const probePath = resolve(import.meta.dirname, '__remote_proxy_probe.ts') +const artifactPath = resolve(import.meta.dirname, '__remote_proxy_artifact.d.ts') +const protocolPath = resolve(root, 'packages/typert/protocol/src/types.ts') +const proxyPath = resolve(import.meta.dirname, '../src/remote-proxy.ts') +const protocolEntry = resolve(import.meta.dirname, '__remote_protocol.d.ts') +const cordisEntry = resolve(import.meta.dirname, '__remote_cordis.d.ts') + +function modulePath(path: string): string { + return JSON.stringify(path.replaceAll('\\', '/')) +} + +function virtualKey(path: string): string { + const normalized = path.replaceAll('\\', '/') + return ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase() +} + +function compile(source: string, artifact?: string) { + const config = ts.readConfigFile(resolve(root, 'tsconfig.base.client.json'), filename => ts.sys.readFile(filename)) + if (config.error !== undefined) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root) + const options: ts.CompilerOptions = { + ...parsed.options, + composite: false, + incremental: false, + noEmit: true, + declaration: false, + declarationMap: false, + rewriteRelativeImportExtensions: false, + types: ['node'], + paths: { + '@deepseek-ai/cordis': [cordisEntry], + '@deepseek-ai/dsh-typert-protocol': [protocolEntry], + '@deepseek-ai/fixture/remote': [artifactPath], + }, + } + // The real protocol and Mock alias are compiled; only unrelated Cordis/runtime assembly is excluded. + const virtual = new Map([ + [virtualKey(probePath), source], + [virtualKey(protocolEntry), `export * from ${modulePath(protocolPath)}\nexport { RemoteError } from ${modulePath(resolve(root, 'packages/typert/protocol/src/remote-error.ts'))}`], + [virtualKey(cordisEntry), 'export interface Context {}\nexport interface Events {}'], + ]) + if (artifact !== undefined) virtual.set(virtualKey(artifactPath), artifact) + const host = ts.createCompilerHost(options, true) + const read = host.readFile.bind(host) + const exists = host.fileExists.bind(host) + const hidden = (path: string): boolean => path.replaceAll('\\', '/').includes('/typert.remote-client.') + host.fileExists = path => virtual.has(virtualKey(path)) || !hidden(path) && exists(path) + host.readFile = path => virtual.get(virtualKey(path)) ?? (hidden(path) ? undefined : read(path)) + host.getSourceFile = (path, languageVersion) => { + const text = host.readFile(path) + return text === undefined ? undefined : ts.createSourceFile(path, text, languageVersion, true) + } + const program = ts.createProgram([probePath], options, host) + expect(program.getSourceFile(protocolPath)).toBeDefined() + expect(program.getSourceFile(proxyPath)).toBeDefined() + expect(program.getSourceFiles().some(file => hidden(file.fileName))).toBe(false) + expect(program.getSourceFile(artifactPath) !== undefined).toBe(artifact !== undefined) + const diagnostics = ts.getPreEmitDiagnostics(program) + const formatted = ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: path => path, + getCurrentDirectory: () => root, + getNewLine: () => '\n', + }) + expect(diagnostics.length, formatted).toBe(0) +} + +describe('RemoteMock proxy types', { timeout: 60_000 }, () => { + it('keeps production closed while an empty generated map permits Mock calls and overrides', () => { + compile(` +import type { TypertRemoteNamespace, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { MockedRemote } from '../src/remote-proxy.ts' +type IsAny = 0 extends (1 & T) ? true : false +declare const local: MockedRemote +const loose: IsAny = true +local.fixture.echo(123, 'unbuilt') +local.fixture.echo.mockResolvedValueOnce({ arbitrary: 'answer' }) +local.fixture.echo.mockImplementation((value: unknown, extra: unknown) => [value, extra]) +declare const production: TypertClientRemote +declare const strict: TypertRemoteNamespace<'fixture'> +// @ts-expect-error -- Importing the Mock alias cannot add a production namespace. +production.fixture.echo('unbuilt') +// @ts-expect-error -- Production methods stay unavailable without generated declarations. +strict.echo('unbuilt') +void loose +`) + }) + + it('preserves generated arguments, results and native spy overrides', () => { + compile(` +import type { MockedRemote } from '../src/remote-proxy.ts' +import type {} from '@deepseek-ai/fixture/remote' +type IsAny = 0 extends (1 & T) ? true : false +declare const local: MockedRemote +const spy = local.fixture.echo +spy.mockImplementation(value => value.length) +const argumentIsTyped: IsAny[0]> = false +const resultIsTyped: IsAny> = false +const result: number = spy('typed') +spy.mockReturnValueOnce(2) +// @ts-expect-error -- Generated arguments remain checked through the native spy. +spy(123) +// @ts-expect-error -- Generated return values remain checked through native overrides. +spy.mockReturnValueOnce('wrong') +// @ts-expect-error -- Known namespaces do not gain arbitrary methods. +local.fixture.missing() +// @ts-expect-error -- A non-empty map does not gain arbitrary namespaces. +local.missing.echo('wrong') +void [argumentIsTyped, resultIsTyped, result] +`, fixtureDeclaration) + }) + + it('keeps partial and empty namespaces closed inside a non-empty map', () => { + compile(` +import type { TypertClientRemote, TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol' +import type { MockedRemote } from '../src/remote-proxy.ts' +import type {} from '@deepseek-ai/fixture/remote' +type PartialMap = { fixture: Pick, 'echo'>; empty: {} } +declare const local: MockedRemote +declare const production: TypertClientRemote +const known: number = local.fixture.echo('known') +// @ts-expect-error -- A partial namespace remains partial. +local.fixture.watch() +// @ts-expect-error -- Only an empty whole map enables the Mock fallback. +local.empty.echo() +// @ts-expect-error -- Local Mock types cannot open production namespace lookup. +production.notANamespace.echo() +void known +`, fixtureDeclaration) + }) + + // Ordinary built Client tsc checks these generated business types; Vitest itself remains build-free. + it('derives real settings mocks from the generated namespace map and native deep mapping', () => { + type Settings = RemoteMock['remote']['settings'] + type Generated = TypertRemoteNamespace<'settings'> + expectTypeOf().toEqualTypeOf>() + expectTypeOf>().not.toBeAny() + expectTypeOf>().not.toBeAny() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf[1]>().not.toBeAny() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf<[number, boolean, string]>().not.toExtend>() + expectTypeOf<{ ok: true; value: number }>().not.toExtend>>() + expectTypeOf().not.toHaveProperty('missing') + }) + + it('retains the native/generated type dependencies only in the proxy declaration', () => { + const source = readFileSync(proxyPath, 'utf8') + const declaration = ts.transpileDeclaration(source, { fileName: proxyPath }) + expect(declaration.diagnostics ?? []).toHaveLength(0) + expect(declaration.outputText).toContain('MaybeMockedDeep') + expect(declaration.outputText).toContain('TypertRemoteNamespaceMap') + const runtime = ts.transpileModule(source, { + fileName: proxyPath, + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2024 }, + }) + expect(runtime.outputText).not.toContain('@deepseek-ai/dsh-typert-protocol') + expect(runtime.outputText).not.toContain('@vitest/spy') + }) +}) + +/** Synthetic Remote methods exercise the mapping without copying business signatures. */ +const fixtureDeclaration = ` +import type { TypertRemoteNamespace } from '@deepseek-ai/dsh-typert-protocol' +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteMap { + 'fixture/echo': (value: string) => number + 'fixture/watch': (after: number, signal?: AbortSignal) => AsyncIterable + } + interface TypertRemoteNamespaceMap { + fixture: TypertRemoteNamespace<'fixture'> + } +} +` diff --git a/packages/test-support/remote-mock/tests/proxy.client.spec.ts b/packages/test-support/remote-mock/tests/proxy.client.spec.ts new file mode 100644 index 0000000000..ba068d70d2 --- /dev/null +++ b/packages/test-support/remote-mock/tests/proxy.client.spec.ts @@ -0,0 +1,271 @@ +/** Native namespace mocks shared by local callers and the Connection carrier. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts' + +const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] }) +const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } } + +async function drain(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} + +describe('RemoteMock.remote unary proxies', () => { + it('caches native namespace/method identities and leaves thenable/symbol probes inert', async () => { + const mock = RemoteMock.create() + const settings = mock.remote.settings + const describe = settings.describe + expect(mock.remote.settings).toBe(settings) + expect(settings.describe).toBe(describe) + expect(vi.isMockFunction(describe)).toBe(true) + expect(RemoteMock.create().remote.settings.describe).not.toBe(describe) + for (const value of [mock.remote, settings]) { + expect(Reflect.get(value, 'then')).toBeUndefined() + expect(Reflect.get(value, Symbol.iterator)).toBeUndefined() + expect(Reflect.get(value, Symbol.toStringTag)).toBeUndefined() + expect(await Promise.resolve(value)).toBe(value) + } + expect(describe).not.toHaveBeenCalled() + expect(mock.endpoints()).toEqual(['$events', 'settings/describe']) + expect(mock.log.calls()).toEqual([]) + expect(mock.log.streams()).toEqual([]) + expect(mock.log.unmatched()).toEqual([]) + }) + + it('consumes native one-shot answers in FIFO order across direct and carrier calls, then uses the default handler', async () => { + const fallback = describeValue(false) + const local = describeValue(true) + const dispatched = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } } + const carrier = describeValue(true) + const handler = vi.fn(() => fallback) + const mock = RemoteMock.create().unary('settings/describe', handler) + const describe = mock.remote.settings.describe + describe.mockReturnValueOnce(Promise.resolve(local)).mockResolvedValueOnce(dispatched).mockResolvedValueOnce(carrier) + + await expect(describe()).resolves.toBe(local) + await expect(mock.dispatch('settings/describe', [])).resolves.toBe(dispatched) + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(carrier) + await expect(describe()).resolves.toBe(fallback) + + expect(handler).toHaveBeenCalledOnce() + expect(describe.mock.calls).toEqual([[], [], [], []]) + expect(mock.log.calls('settings/describe').map(call => [call.args, call.state, call.result])) + .toEqual([[[], 'answered', dispatched], [[], 'answered', carrier]]) + mock.assertNoUnmatched() + }) + + it('records every business argument while the carrier keeps its signal out of positional args', async () => { + const mock = RemoteMock.create() + const mutate = mock.remote.settings.mutate + const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 }) + const ops = [{ op: 'set' as const, path: ['preference'], value: 'en' }] + mutate.mockResolvedValue(result) + + await expect(mutate('locale', ops, 7)).resolves.toBe(result) + await expect(mock.rpc.call('/api', 'settings/mutate', { args: ['locale', ops, 7] }, new AbortController().signal)) + .resolves.toBe(result) + expect(mutate.mock.calls).toEqual([['locale', ops, 7], ['locale', ops, 7]]) + expect(mutate.mock.calls[1]?.[1]).toBe(ops) + expect(mock.log.calls('settings/mutate').map(call => call.args)).toEqual([['locale', ops, 7]]) + }) + + it('keeps native one-shot answers local to each mock when they load the same default table', async () => { + const fallback = describeValue(false) + const table = { unary: { 'settings/describe': fallback } } + const a = RemoteMock.create().load(table) + const b = RemoteMock.create().load(table) + const first = describeValue(true) + const second = { ...describeValue(true), value: { writable: false, hasDocument: true, namespaces: [] } } + a.remote.settings.describe.mockResolvedValueOnce(first) + b.remote.settings.describe.mockResolvedValueOnce(second) + + await expect(a.dispatch('settings/describe', [])).resolves.toBe(first) + await expect(a.dispatch('settings/describe', [])).resolves.toBe(fallback) + expect(b.remote.settings.describe).not.toHaveBeenCalled() + await expect(b.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(second) + await expect(b.remote.settings.describe()).resolves.toBe(fallback) + }) + + it('keeps queued overrides on mockClear and restores the live default rule on mockReset', async () => { + const original = describeValue(false) + const updated = describeValue(true) + const override = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } } + const mock = RemoteMock.create().unary('settings/describe', original) + const describe = mock.remote.settings.describe + await describe() + describe.mockResolvedValueOnce(override) + describe.mockClear() + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(override) + expect(describe.mock.calls).toEqual([[]]) + + describe.mockResolvedValue(override) + mock.load({ unary: { 'settings/describe': updated } }) + await expect(mock.dispatch('settings/describe', [])).resolves.toBe(override) + describe.mockReset() + await expect(mock.dispatch('settings/describe', [])).resolves.toBe(updated) + expect(mock.remote.settings.describe).toBe(describe) + expect(describe.mock.calls).toEqual([[]]) + expect(mock.log.calls('settings/describe').map(call => call.result)).toEqual([override, override, updated]) + }) + + it('reports missing defaults after native one-shot answers are exhausted', async () => { + const mock = RemoteMock.create() + const describe = mock.remote.settings.describe + expect(mock.modeOf('settings/describe')).toBeUndefined() + const answer = describeValue(false) + describe.mockResolvedValueOnce(answer) + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).resolves.toBe(answer) + expect(() => describe()).toThrow('no rule for settings/describe') + await expect(mock.dispatch('settings/describe', [])).rejects.toThrow('no rule for settings/describe') + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toThrow('no rule for settings/describe') + expect(describe).toHaveBeenCalledTimes(4) + expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['answered', answer]]) + expect(mock.log.unmatched()).toEqual(Array.from({ length: 3 }, () => ({ endpoint: 'settings/describe', mode: 'unary' }))) + expect(() => { mock.assertNoUnmatched() }).toThrow('3 unmatched request(s)') + }) + + it('records native rejected answers as failed carrier calls without unmatched entries', async () => { + const mock = RemoteMock.create() + const failure = new Error('mock service unavailable') + const describe = mock.remote.settings.describe.mockRejectedValueOnce(failure) + await expect(mock.rpc.call('/api', 'settings/describe', { args: [] })).rejects.toBe(failure) + expect(describe.mock.calls).toEqual([[]]) + expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]]) + mock.assertNoUnmatched() + }) + + it('preserves synchronous native override failures in the async carrier log', async () => { + const mock = RemoteMock.create() + const failure = new Error('handler threw') + const describe = mock.remote.settings.describe.mockImplementation(() => { throw failure }) + await expect(mock.dispatch('settings/describe', [])).rejects.toBe(failure) + expect(describe).toHaveBeenCalledOnce() + expect(mock.log.calls('settings/describe').map(call => [call.state, call.result])).toEqual([['failed', failure]]) + mock.assertNoUnmatched() + }) +}) + +describe('RemoteMock.remote stream proxies', () => { + it('selects the cached spy for the current mode and preserves its identity across script replacement', async () => { + const mock = RemoteMock.create() + const session = mock.remote.session + const unary = session.control + expect(() => unary()).toThrow('no rule for session/control') + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'unary' }]) + + mock.stream('session/control', frames([baseline])).stream('session/control') + const stream = session.control + expect(mock.remote.session).toBe(session) + expect(stream).not.toBe(unary) + expect(vi.isMockFunction(stream)).toBe(true) + expect(session.control).toBe(stream) + const opened = stream() + mock.stream('session/control', frames([])) + expect(session.control).toBe(stream) + await expect(drain(opened)).resolves.toEqual([baseline]) + await expect(drain(stream())).resolves.toEqual([]) + expect(unary).toHaveBeenCalledOnce() + expect(stream).toHaveBeenCalledTimes(2) + expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['ended', 'ended']) + }) + + it('shares stream spies with the carrier, preserves its args, and reflects each caller cancellation', async () => { + const signals: AbortSignal[] = [] + const script = vi.fn((_args: readonly unknown[], stream: StreamHandle) => { + signals.push(stream.signal) + stream.push(baseline) + }) + const mock = RemoteMock.create().stream('session/control', script) + const localController = new AbortController() + const wireController = new AbortController() + onTestFinished(async () => { + localController.abort() + wireController.abort() + await mock.streams.drained('session/control') + }) + const control = mock.remote.session.control + const request = { after: 7 } + const local = control(localController.signal)[Symbol.asyncIterator]() + const wire = mock.rpc.open!('/api', 'session/control', { args: [request] }, wireController.signal)[Symbol.asyncIterator]() + await expect(local.next()).resolves.toEqual({ value: baseline, done: false }) + await expect(wire.next()).resolves.toEqual({ value: baseline, done: false }) + expect(control.mock.calls).toEqual([[localController.signal], [request, wireController.signal]]) + expect(script.mock.calls.map(([args]) => args)).toEqual([[], [request]]) + expect(signals).toHaveLength(2) + expect(signals.map(signal => signal.aborted)).toEqual([false, false]) + expect(mock.log.streams('session/control').map(entry => entry.args)).toEqual([[], [request]]) + + const waiting = local.next() + localController.abort() + await expect(waiting).resolves.toEqual({ value: undefined, done: true }) + await wire.return!() + await mock.streams.drained('session/control') + expect(signals.map(signal => signal.aborted)).toEqual([true, true]) + expect(mock.streams.push('session/control', baseline)).toBe(0) + expect(mock.log.streams('session/control').map(entry => entry.state)).toEqual(['cancelled', 'cancelled']) + }) + + it('provides a cancellable default signal when a local stream call omits one', async () => { + let openedSignal: AbortSignal | undefined + const mock = RemoteMock.create().stream('session/control', (_args, stream) => { openedSignal = stream.signal }) + const control = mock.remote.session.control + const reader = control()[Symbol.asyncIterator]() + onTestFinished(async () => { await reader.return!() }) + expect(openedSignal).toBeInstanceOf(AbortSignal) + expect(openedSignal?.aborted).toBe(false) + const waiting = reader.next() + await reader.return!() + await expect(waiting).resolves.toEqual({ value: undefined, done: true }) + expect(control.mock.calls).toEqual([[]]) + expect(mock.log.streams('session/control')[0]?.state).toBe('cancelled') + }) + + it('excludes unread native stream overrides from controlled streams and restores the script on reset', async () => { + const mock = RemoteMock.create().stream('session/control', frames([baseline])) + const control = mock.remote.session.control + const signal = new AbortController().signal + let consumed = false + async function* response() { + consumed = true + yield baseline + } + const native = response() + onTestFinished(async () => { await native.return(undefined) }) + control.mockReturnValueOnce(native) + const overridden = mock.open('session/control', [], signal) + expect(overridden).toBe(native) + await expect(mock.streams.drained('session/control')).resolves.toBeUndefined() + expect(consumed).toBe(false) + expect(control.mock.calls).toEqual([[signal]]) + expect(mock.log.streams('session/control')).toHaveLength(0) + await expect(drain(overridden)).resolves.toEqual([baseline]) + expect(consumed).toBe(true) + + control.mockReset() + await expect(drain(mock.rpc.open!('/api', 'session/control', { args: [] }, signal))).resolves.toEqual([baseline]) + await expect(mock.streams.opened('session/control', 1)).resolves.toBeUndefined() + await expect(mock.streams.drained('session/control')).resolves.toBeUndefined() + expect(mock.remote.session.control).toBe(control) + expect(control.mock.calls).toEqual([[signal]]) + expect(mock.log.streams('session/control').map(entry => [entry.state, entry.pushed])).toEqual([['ended', 1]]) + mock.assertNoUnmatched() + }) + + it('requires an explicit stream script and keeps live failures observable by the consumer', async () => { + const mock = RemoteMock.create().stream('session/control') + expect(() => mock.remote.session.control()).toThrow('no rule for session/control') + expect(mock.log.unmatched()).toEqual([{ endpoint: 'session/control', mode: 'stream' }]) + mock.stream('session/control', openStream()) + const controller = new AbortController() + onTestFinished(() => { controller.abort() }) + const reader = mock.remote.session.control(controller.signal)[Symbol.asyncIterator]() + const failure = new Error('stream disconnected') + const pending = expect(reader.next()).rejects.toBe(failure) + expect(mock.streams.fail('session/control', failure)).toBe(1) + await pending + await mock.streams.drained('session/control') + expect(mock.log.streams('session/control')[0]?.state).toBe('failed') + expect(mock.streams.push('session/control', baseline)).toBe(0) + }) +}) diff --git a/packages/test-support/remote-mock/tests/rpc.client.spec.ts b/packages/test-support/remote-mock/tests/rpc.client.spec.ts new file mode 100644 index 0000000000..2eb0a620bc --- /dev/null +++ b/packages/test-support/remote-mock/tests/rpc.client.spec.ts @@ -0,0 +1,43 @@ +/** The Connection carrier face: payload forms, streams, unmatched rejections, and abort. */ +import { describe, expect, it } from 'vitest' +import { RemoteMock, ok, openStream } from '../src/index.ts' + +const idle = (): AbortSignal => new AbortController().signal + +describe('RemoteMock.rpc', () => { + it('calls and opens by endpoint, taking the proxies\' positional args or the Gateway\'s single object', async () => { + const mock = RemoteMock.create() + .unary('session/list', ok({ items: [] })) + .unary('$events/result', (...args) => ok(args)) + .stream('session/control', openStream([{ type: 'baseline' }])) + await expect(mock.rpc.call('/api', 'session/list', { args: [{}] })).resolves.toEqual({ ok: true, value: { items: [] } }) + await expect(mock.rpc.call('/api', '$events/result', { args: { clientId: 'c' } }, idle())).resolves.toEqual({ ok: true, value: [{ clientId: 'c' }] }) + const stream = mock.rpc.open!('/api', 'session/control', { args: [{ since: 1 }] }, idle())[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ value: { type: 'baseline' }, done: false }) + expect(mock.log.calls().map(call => [call.endpoint, call.args])).toEqual([['session/list', [{}]], ['$events/result', [{ clientId: 'c' }]]]) + expect(mock.log.streams('session/control').map(open => open.args)).toEqual([[{ since: 1 }]]) + }) + + it('rejects malformed payloads and unmatched endpoints, logging the miss', async () => { + const mock = RemoteMock.create() + await expect(mock.rpc.call('/api', 'a/b', 'bare')).rejects.toThrow('remote-mock: payload of a/b must be { args: unknown[] | object }') + await expect(mock.rpc.call('/api', 'a/b', { args: 'x' })).rejects.toThrow('must be { args: unknown[] | object }') + expect(() => mock.rpc.open!('/api', 'a/b', null, idle())).toThrow('payload of a/b must be { args: unknown[] | object }') + await expect(mock.rpc.call('/api', 'a/b', { args: [] })).rejects.toThrow('remote-mock: no rule for a/b') + expect(mock.log.unmatched()).toEqual([{ endpoint: 'a/b', mode: 'unary' }]) + }) + + it('rejects an aborted call: immediately when already aborted, with the reason while the rule is pending', async () => { + const mock = RemoteMock.create().unary('slow/call', () => new Promise(() => {})) + const pending = new AbortController() + const request = mock.rpc.call('/api', 'slow/call', { args: [] }, pending.signal) + pending.abort(new Error('caller left')) + await expect(request).rejects.toThrow('caller left') + const already = new AbortController() + already.abort('not an error') + await expect(mock.rpc.call('/api', 'slow/call', { args: [] }, already.signal)).rejects.toThrow('remote-mock: call aborted') + await expect(mock.rpc.call('/api', 'session/list', { args: [] }, idle())).rejects.toThrow('no rule for session/list') + // The abort rejects the caller; the never-settling rules stay pending in the log, and the miss is not a call. + expect(mock.log.calls().map(call => [call.endpoint, call.state])).toEqual([['slow/call', 'pending'], ['slow/call', 'pending']]) + }) +}) diff --git a/packages/test-support/remote-mock/tests/streams.client.spec.ts b/packages/test-support/remote-mock/tests/streams.client.spec.ts new file mode 100644 index 0000000000..ec41ba902f --- /dev/null +++ b/packages/test-support/remote-mock/tests/streams.client.spec.ts @@ -0,0 +1,198 @@ +/** Stream scripts, live stream control, cancellation, and the built-in `$events` opening. */ +import { describe, expect, it } from 'vitest' +import { RemoteMock, frames, openStream } from '../src/index.ts' + +const idle = (): AbortSignal => new AbortController().signal + +async function drain(source: AsyncIterable): Promise { + const items: unknown[] = [] + for await (const item of source) items.push(item) + return items +} + +async function take(source: AsyncIterable, count: number): Promise { + const items: unknown[] = [] + for await (const item of source) { + items.push(item) + if (items.length === count) break + } + return items +} + +describe('RemoteMock streams', () => { + it('yields frames() then ends, logging the open, and throws on an unmatched endpoint', async () => { + const mock = RemoteMock.create().stream('s/f', frames([{ n: 1 }, { n: 2 }])) + await expect(drain(mock.open('s/f', [{ id: 'a' }], idle()))).resolves.toEqual([{ n: 1 }, { n: 2 }]) + expect(mock.log.streams('s/f')).toEqual([{ endpoint: 's/f', args: [{ id: 'a' }], state: 'ended', pushed: 2, seq: 1 }]) + expect(mock.log.streams('other')).toEqual([]) + expect(() => mock.open('s/g', [], idle())).toThrow('remote-mock: no rule for s/g; registered: $events, s/f') + expect(mock.log.unmatched()).toEqual([{ endpoint: 's/g', mode: 'stream' }]) + }) + + it('keeps openStream() open for pushes, filters by open args, and reports delivery counts', async () => { + const mock = RemoteMock.create().stream('s/f', openStream(['hello'])) + const sessionOf = ([request]: readonly unknown[]): string => (request as { sessionId: string }).sessionId + const a = mock.open('s/f', [{ sessionId: 'a' }], idle()) + const b = mock.open('s/f', [{ sessionId: 'b' }], idle()) + expect(mock.streams.push('s/f', 'only-b', open => sessionOf(open) === 'b')).toBe(1) + expect(mock.streams.push('s/f', 'both')).toBe(2) + expect(mock.streams.fail('s/f', new Error('gone'), open => sessionOf(open) === 'a')).toBe(1) + expect(mock.streams.end('s/f')).toBe(1) + await expect(drain(b)).resolves.toEqual(['hello', 'only-b', 'both']) + await expect(drain(a)).rejects.toThrow('gone') + expect(mock.log.streams().map(entry => [entry.state, entry.pushed])).toEqual([['failed', 2], ['ended', 3]]) + expect(mock.streams.push('s/f', 'late')).toBe(0) + }) + + it('resolves a pending read on push, end, or fail; a second concurrent read is a bug; settling twice is a no-op', async () => { + const mock = RemoteMock.create().stream('s/f', openStream()) + const first = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + const pending = first.next() + await expect(first.next()).rejects.toThrow('remote-mock: s/f stream has one consumer') + mock.streams.push('s/f', 'x') + await expect(pending).resolves.toEqual({ value: 'x', done: false }) + const ending = first.next() + mock.streams.end('s/f') + await expect(ending).resolves.toEqual({ value: undefined, done: true }) + const second = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + const failing = second.next() + mock.streams.fail('s/f', new Error('boom')) + await expect(failing).rejects.toThrow('boom') + await expect(second.next()).rejects.toThrow('boom') + expect(mock.streams.end('s/f')).toBe(0) + const settled = new AbortController() + const ended = mock.open('s/f', [], settled.signal) + mock.streams.end('s/f') + settled.abort() + await expect(drain(ended)).resolves.toEqual([]) + expect(mock.log.streams().map(entry => entry.state)).toEqual(['ended', 'failed', 'ended']) + }) + + it('lets a consumer that returns after the producer ended drop the rest, so drained() settles', async () => { + const mock = RemoteMock.create().stream('s/f', frames(['a', 'b'])) + const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + await expect(reader.next()).resolves.toEqual({ value: 'a', done: false }) + const pending = mock.streams.drained('s/f') + await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) + await expect(pending).resolves.toBeUndefined() + expect(mock.log.streams('s/f')).toEqual([{ endpoint: 's/f', args: [], state: 'ended', pushed: 2, seq: 1 }]) + expect(mock.streams.push('s/f', 'late')).toBe(0) + }) + + it('treats consumer abort or return() as cancellation and drops later pushes', async () => { + const mock = RemoteMock.create().stream('s/f', openStream(['queued'])) + const controller = new AbortController() + const cancelled = mock.open('s/f', [], controller.signal)[Symbol.asyncIterator]() + await expect(cancelled.next()).resolves.toEqual({ value: 'queued', done: false }) + const waiting = cancelled.next() + controller.abort() + await expect(waiting).resolves.toEqual({ value: undefined, done: true }) + expect(mock.streams.push('s/f', 'after')).toBe(0) + const aborted = new AbortController() + aborted.abort() + await expect(drain(mock.open('s/f', [], aborted.signal))).resolves.toEqual([]) + await expect(take(mock.open('s/f', [], idle()), 1)).resolves.toEqual(['queued']) + expect(mock.log.streams().map(entry => entry.state)).toEqual(['cancelled', 'cancelled', 'cancelled']) + }) + + it('aborts the stream handle signal when the consumer returns', async () => { + let signal: AbortSignal | undefined + const mock = RemoteMock.create().stream('s/f', (_args, stream) => { + signal = stream.signal + stream.push('first') + }) + const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + await expect(reader.next()).resolves.toEqual({ value: 'first', done: false }) + await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) + expect({ + state: mock.log.streams('s/f')[0]?.state, + signalAborted: signal?.aborted, + }).toEqual({ state: 'cancelled', signalAborted: true }) + }) + + it('runs script functions with the open args and fails the stream when they throw or reject', async () => { + const mock = RemoteMock.create() + .stream('s/echo', (args, stream) => { + stream.push(args) + stream.end() + stream.end() + stream.fail(new Error('too late')) + }) + .stream('s/async', async (_args, stream) => { + await Promise.resolve() + stream.push('later') + stream.end() + }) + .stream('s/throws', () => { throw new Error('sync boom') }) + .stream('s/rejects', () => Promise.reject(new Error('async boom'))) + .stream('s/odd', () => { throw 'string reason' }) + await expect(drain(mock.open('s/echo', [{ a: 1 }], idle()))).resolves.toEqual([[{ a: 1 }]]) + await expect(drain(mock.open('s/async', [], idle()))).resolves.toEqual(['later']) + await expect(drain(mock.open('s/throws', [], idle()))).rejects.toThrow('sync boom') + await expect(drain(mock.open('s/rejects', [], idle()))).rejects.toThrow('async boom') + await expect(drain(mock.open('s/odd', [], idle()))).rejects.toThrow('string reason') + }) + + it('drained() settles once the consumer has pulled every push and waits again, or the stream closed', async () => { + const mock = RemoteMock.create().stream('s/f', openStream(['first'])) + await expect(mock.streams.drained('s/f')).resolves.toBeUndefined() // nothing open: nothing to drain + const reader = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + const unread = mock.streams.drained('s/f') + let settled = false + void unread.then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) // 'first' is queued and nobody has pulled it + await expect(reader.next()).resolves.toEqual({ value: 'first', done: false }) + await Promise.resolve() + expect(settled).toBe(false) // pulled, but the consumer is not waiting for more yet + const waiting = reader.next() + await expect(unread).resolves.toBeUndefined() + mock.streams.push('s/f', 'second') + await expect(waiting).resolves.toEqual({ value: 'second', done: false }) + mock.streams.push('s/f', 'third') + mock.streams.end('s/f') + const ended = mock.streams.drained('s/f') + let endedSettled = false + void ended.then(() => { endedSettled = true }) + await Promise.resolve() + expect(endedSettled).toBe(false) // ended, but 'third' is still queued + await expect(reader.next()).resolves.toEqual({ value: 'third', done: false }) + await expect(ended).resolves.toBeUndefined() // queue empty: a settled stream counts as drained + await expect(reader.next()).resolves.toEqual({ value: undefined, done: true }) + await expect(reader.return!()).resolves.toEqual({ value: undefined, done: true }) // returning a settled stream changes nothing + const second = mock.open('s/f', [], idle())[Symbol.asyncIterator]() + await expect(second.next()).resolves.toEqual({ value: 'first', done: false }) + const parked = second.next() + await expect(mock.streams.drained('s/f')).resolves.toBeUndefined() // its consumer is waiting + const controller = new AbortController() + const third = mock.open('s/f', [], controller.signal) + const cancelling = mock.streams.drained('s/f', () => true) // third holds 'first' that nobody has read + controller.abort() + await expect(cancelling).resolves.toBeUndefined() // cancellation discards the queue: closed counts as drained + await expect(drain(third)).resolves.toEqual([]) + mock.streams.end('s/f') + await expect(parked).resolves.toEqual({ value: undefined, done: true }) + }) + + it('declares a stream without a script: modeOf answers stream and an open is a stream miss', () => { + const mock = RemoteMock.create().load({ streams: ['s/declared'] }) + expect(mock.modeOf('s/declared')).toBe('stream') + expect(mock.endpoints()).toEqual(['$events', 's/declared']) + expect(() => mock.open('s/declared', [], idle())).toThrow('remote-mock: no rule for s/declared; registered: $events, s/declared') + expect(mock.log.unmatched()).toEqual([{ endpoint: 's/declared', mode: 'stream' }]) + mock.stream('s/declared', frames(['now'])) + expect(mock.modeOf('s/declared')).toBe('stream') + }) + + it('waits for opens with opened(), and answers $events with one ready frame per generation', async () => { + const mock = RemoteMock.create({ host: { home: '/home/me' } }) + const second = mock.streams.opened('$events', 2) + const first = mock.open('$events', [{}], idle()) + await expect(take(first, 1)).resolves.toEqual([{ type: 'ready', clientId: 'mock-client-1', host: { home: '/home/me' } }]) + const again = mock.open('$events', [{}], idle()) + await expect(second).resolves.toBeUndefined() + await expect(mock.streams.opened('$events', 1)).resolves.toBeUndefined() + await expect(take(again, 1)).resolves.toEqual([{ type: 'ready', clientId: 'mock-client-2', host: { home: '/home/me' } }]) + expect(RemoteMock.create().modeOf('$events')).toBe('stream') + }) +}) diff --git a/packages/test-support/remote-mock/tests/unary.client.spec.ts b/packages/test-support/remote-mock/tests/unary.client.spec.ts new file mode 100644 index 0000000000..7957689c96 --- /dev/null +++ b/packages/test-support/remote-mock/tests/unary.client.spec.ts @@ -0,0 +1,107 @@ +/** Registered unary defaults and the carrier's request and outcome log. */ +import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { RemoteMock, ok, openStream, type RemoteTable, type UnaryRuleFn } from '../src/index.ts' + +describe('RemoteMock unary answers', () => { + it('answers a fixed value verbatim and logs args and result with a global seq', async () => { + const mock = RemoteMock.create().unary('session/list', ok({ items: [] })) + await expect(mock.dispatch('session/list', [{}])).resolves.toEqual({ ok: true, value: { items: [] } }) + expect(mock.log.calls()).toEqual([{ endpoint: 'session/list', args: [{}], state: 'answered', result: { ok: true, value: { items: [] } }, seq: 1 }]) + expect(mock.log.calls('other/x')).toEqual([]) + expect(mock.modeOf('session/list')).toBe('unary') + expect(mock.modeOf('other/x')).toBeUndefined() + expect(mock.endpoints()).toEqual(['$events', 'session/list']) + }) + + it('replaces defaults on registration and table reload, including an explicit undefined answer', async () => { + const table: RemoteTable = { unary: { 'a/b': 'table' } } + const mock = RemoteMock.create().load(table) + await expect(mock.dispatch('a/b', [])).resolves.toBe('table') + mock.unary('a/b', 'replacement') + await expect(mock.dispatch('a/b', [])).resolves.toBe('replacement') + mock.load(table) + await expect(mock.dispatch('a/b', [])).resolves.toBe('table') + mock.load({ unary: { 'a/b': undefined } }) + await expect(mock.dispatch('a/b', [])).resolves.toBeUndefined() + expect(mock.modeOf('a/b')).toBe('unary') + expect(mock.endpoints()).toEqual(['$events', 'a/b']) + mock.assertNoUnmatched() + }) + + it('passes positional arguments to native spies and leaves scenario state with the handler', async () => { + let n = 0 + const handler = vi.fn((request: { id: number }, extra?: string) => { + expectTypeOf(request).toEqualTypeOf<{ id: number }>() + expectTypeOf(extra).toEqualTypeOf() + return ok(++n) + }) + const mock = RemoteMock.create() + .unary('a/b', handler) + .unary('a/c', () => Promise.resolve(n)) + await expect(mock.dispatch('a/b', [{ id: 1 }])).resolves.toEqual(ok(1)) + await expect(mock.dispatch('a/b', [{ id: 2 }, 'more'])).resolves.toEqual(ok(2)) + await expect(mock.dispatch('a/c', [])).resolves.toBe(2) + expect(handler).toHaveBeenCalledTimes(2) + expect(handler).toHaveBeenNthCalledWith(1, { id: 1 }) + expect(handler).toHaveBeenNthCalledWith(2, { id: 2 }, 'more') + }) + + it('keeps raw answers and typed handlers in tables without wrapping their arguments', async () => { + const handler: UnaryRuleFn<[namespace: string, value: number]> = (namespace, value) => ok({ namespace, value }) + const mock = RemoteMock.create().load({ unary: { 'a/read': handler, 'a/void': undefined, 'a/null': null } }) + await expect(mock.dispatch('a/read', ['settings', 2])).resolves.toEqual(ok({ namespace: 'settings', value: 2 })) + await expect(mock.dispatch('a/void', [])).resolves.toBeUndefined() + await expect(mock.dispatch('a/null', [])).resolves.toBeNull() + mock.unary('a/inferred', (value) => { + expectTypeOf(value).toEqualTypeOf() + return value + }) + await expect(mock.dispatch('a/inferred', [3])).resolves.toBe(3) + }) + + it('logs a call when its rule is selected and settles it with the rule: pending, answered, or failed', async () => { + const gate = Promise.withResolvers() + const mock = RemoteMock.create() + .unary('a/slow', () => gate.promise) + .unary('a/throws', () => { throw new Error('sync boom') }) + .unary('a/rejects', () => Promise.reject(new Error('async boom'))) + const slow = mock.dispatch('a/slow', [1]) + expect(mock.log.calls('a/slow')).toEqual([{ endpoint: 'a/slow', args: [1], state: 'pending', result: undefined, seq: 1 }]) + gate.resolve('late') + await expect(slow).resolves.toBe('late') + expect(mock.log.calls('a/slow')[0]).toMatchObject({ state: 'answered', result: 'late' }) + await expect(mock.dispatch('a/throws', [])).rejects.toThrow('sync boom') + await expect(mock.dispatch('a/rejects', [])).rejects.toThrow('async boom') + expect(mock.log.calls().map(call => [call.endpoint, call.state, call.result instanceof Error ? call.result.message : call.result])) + .toEqual([['a/slow', 'answered', 'late'], ['a/throws', 'failed', 'sync boom'], ['a/rejects', 'failed', 'async boom']]) + expect(mock.log.unmatched()).toEqual([]) + }) + + it('requests() lists the first arg of calls and opens in order, per endpoint or across all but the Gateway\'s own', async () => { + const mock = RemoteMock.create() + .unary('session/page', ok(null)) + .stream('session/follow', openStream([])) + const signal = new AbortController().signal + await mock.dispatch('session/page', [{ throughSeq: 5 }]) + mock.open('session/follow', [{ address: 'a' }], signal) + mock.open('$events', [{}], signal) + await mock.dispatch('session/page', [{ throughSeq: 9 }, 'extra']) + expect((mock.log.requests('session/page') as { throughSeq: number }[]).map(request => request.throughSeq)).toEqual([5, 9]) + expect(mock.log.requests('session/follow')).toEqual([{ address: 'a' }]) + expect(mock.log.requests('$events')).toEqual([{}]) + expect(mock.log.requests()).toEqual([{ throughSeq: 5 }, { address: 'a' }, { throughSeq: 9 }]) + expect(RemoteMock.create().log.requests()).toEqual([]) + }) + + it('loads a whole table', () => { + const mock = RemoteMock.create().load({ + unary: { 'session/list': ok({ items: [] }), 'a/b': 1 }, + stream: { 'session/control': openStream([{ type: 'baseline' }]) }, + }) + expect(mock.modeOf('session/list')).toBe('unary') + expect(mock.modeOf('a/b')).toBe('unary') + expect(mock.modeOf('session/control')).toBe('stream') + expect(() => RemoteMock.create().load({})).not.toThrow() + RemoteMock.create().assertNoUnmatched() + }) +}) diff --git a/packages/test-support/remote-mock/tsconfig.json b/packages/test-support/remote-mock/tsconfig.json new file mode 100644 index 0000000000..1ddb9b70e5 --- /dev/null +++ b/packages/test-support/remote-mock/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../typert/protocol" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../client/connection/tsconfig.client.json" + } + ] +} diff --git a/packages/test-support/remote-mock/tsdown.config.ts b/packages/test-support/remote-mock/tsdown.config.ts new file mode 100644 index 0000000000..1fe5837704 --- /dev/null +++ b/packages/test-support/remote-mock/tsdown.config.ts @@ -0,0 +1,6 @@ +import { clientLibrary } from '../../client/tsdown.client.ts' + +export default clientLibrary( + '@deepseek-ai/dsh-remote-mock', + ['lib/types/index.js'], +) diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index 92c1c56cbd..f3a6468992 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 676d98dada..7eb98180dd 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 21970cbcb9..d92563a06f 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -60,6 +60,7 @@ function validateEvent(event: SessionEvent, trace: TurnTrace, fail: InvariantFai /** Validate one existing log in a single pass and return its tail trace. */ function seedTrace(session: Session, fail: InvariantFailure): TurnTrace { const trace: TurnTrace = { open: false } + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents()) { validateEvent(event, trace, fail) advanceTrace(trace, event) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index b99909fa2a..7365651f21 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -87,6 +87,7 @@ async function boot(configLines: readonly string[]): Promise { } as unknown as NonNullable await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) await ctx.loader.await() + for (const entry of ctx.loader.entries()) await entry.fiber?.await() return ctx } diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 83d831de18..ab65157347 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 09a1445300..d0873289e6 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 2f4b4cae04..979b5b535b 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -212,12 +212,12 @@ describe('typert loader', () => { await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.list()).toHaveLength(1) - await ctx.loader.remove(id) + ctx.loader.remove(id) await ctx.loader.await() // The unmount reconciliation rides a queued microtask flush. await new Promise(resolve => setTimeout(resolve, 20)) expect(ctx.typert.get('@fixture/with-typert#Thing')).toBeUndefined() - await ctx.loader.remove(plainId) + ctx.loader.remove(plainId) await ctx.loader.await() await new Promise(resolve => setTimeout(resolve, 20)) diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index ece54b6a65..199936fcc5 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 1cb89d6249..307410ed20 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 95afd47638..7aa769a1ba 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 2b1627e7c0..b108caf9cc 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded primitive types for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/chunked-list/package.json b/packages/util/chunked-list/package.json index 0796d94d1c..e93ccec925 100644 --- a/packages/util/chunked-list/package.json +++ b/packages/util/chunked-list/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-chunked-list", "description": "Persistent append-only chunked lists with bounded copying and JSON checkpoint validation", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index aa8f431c3a..3d1fb4e136 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index e6312b1afb..37f6d8d589 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/README.i18n.yaml b/packages/util/home-paths/README.i18n.yaml index 686bf4de8b..0268c25623 100644 --- a/packages/util/home-paths/README.i18n.yaml +++ b/packages/util/home-paths/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/home-paths/README.md -README.md: 2ce9810bd29bdb00f5eb2e09d886f31e2ca3b176 -README.zh.md: 0bd46c223992c37be01876597aa239cfc8eb768d +README.md: eb595a7c468ac47128d30e95771a1c0905571834 +README.zh.md: 215317cd42d5d72319cc7af008ba2f4dbe1a26fb diff --git a/packages/util/home-paths/README.md b/packages/util/home-paths/README.md index 2ce9810bd2..eb595a7c46 100644 --- a/packages/util/home-paths/README.md +++ b/packages/util/home-paths/README.md @@ -29,14 +29,17 @@ Use these helpers wherever a package must agree with the rest of the harness abo ### Resolving the home ```ts -import { resolveDshHome, dshHomePath } from '@deepseek-ai/dsh-home-paths' +import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home-paths' const home = resolveDshHome() // configured path, else $DSH_HOME, else ~/.dsh const settings = dshHomePath('settings') // join one child onto the resolved home +const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models ``` An explicit configured path has the highest precedence, then `$DSH_HOME`, then the default `~/.dsh`. An empty or whitespace-only `$DSH_HOME` is treated as unset, so a blank override never resolves the home to the current working directory. +`dshCachePath(...segments)` derives paths from the resolved home's `cache` directory. With no segments it returns the cache directory itself. Pass an initial options object, `dshCachePath({ dshHome: home }, ...segments)`, to use an explicit configured home with the same precedence and tilde expansion. It returns an absolute path without creating directories. + ### Displaying a home For user-facing paths, render the root symbolically rather than as a machine path: the default home displays as `~/.dsh` and any configured home displays as `$DSH_HOME`. The display form never leaks an absolute machine path. diff --git a/packages/util/home-paths/README.zh.md b/packages/util/home-paths/README.zh.md index 0bd46c2239..215317cd42 100644 --- a/packages/util/home-paths/README.zh.md +++ b/packages/util/home-paths/README.zh.md @@ -29,14 +29,17 @@ kind: "package-library" ### 解析主目录 ```ts -import { resolveDshHome, dshHomePath } from '@deepseek-ai/dsh-home-paths' +import { resolveDshHome, dshHomePath, dshCachePath } from '@deepseek-ai/dsh-home-paths' const home = resolveDshHome() // configured path, else $DSH_HOME, else ~/.dsh const settings = dshHomePath('settings') // join one child onto the resolved home +const cache = dshCachePath('models') // $DSH_HOME/cache/models, default ~/.dsh/cache/models ``` 显式配置的路径优先级最高,然后是 `$DSH_HOME`,最后是默认的 `~/.dsh`。空或仅含空白的 `$DSH_HOME` 视为未设置,因此空白的覆盖值绝不会把主目录解析到当前工作目录。 +`dshCachePath(...segments)` 从解析出的主目录下的 `cache` 目录派生路径。不传路径段时返回缓存目录本身。传入首个选项对象 `dshCachePath({ dshHome: home }, ...segments)` 可使用显式配置的主目录,遵循相同的优先级与波浪号展开规则。它返回绝对路径,不会创建目录。 + ### 展示主目录 面向用户的路径请以符号形式渲染根目录,而不是机器路径:默认主目录显示为 `~/.dsh`,任何已配置的主目录显示为 `$DSH_HOME`。展示形式绝不会泄露机器的绝对路径。 diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index be60eef526..020b85dce1 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/src/index.ts b/packages/util/home-paths/src/index.ts index 356489ef9f..0d7b934003 100644 --- a/packages/util/home-paths/src/index.ts +++ b/packages/util/home-paths/src/index.ts @@ -99,6 +99,17 @@ export function dshHomePath(...segments: string[]): string { return join(resolveDshHome(), ...segments) } +/** + * Join path segments onto the resolved Harness home's `cache` directory without creating it; no arguments returns the directory itself. + * @param optionsOrSegment - explicit home override, or the first path segment; omission uses the default home resolution. + * @param segments - additional path segments after the first child, if any. + * @returns the normalized absolute cache path. + */ +export function dshCachePath(optionsOrSegment: { dshHome?: string } | string = {}, ...segments: string[]): string { + if (typeof optionsOrSegment === 'string') return dshHomePath('cache', optionsOrSegment, ...segments) + return join(resolveDshHome(optionsOrSegment.dshHome), 'cache', ...segments) +} + /** * Describe a resolved harness home symbolically for user-facing display. * diff --git a/packages/util/home-paths/tests/home-paths.spec.ts b/packages/util/home-paths/tests/home-paths.spec.ts index 2162febc69..908f7481b3 100644 --- a/packages/util/home-paths/tests/home-paths.spec.ts +++ b/packages/util/home-paths/tests/home-paths.spec.ts @@ -7,6 +7,7 @@ import { DSH_HOME_DIR_NAME, canonicalizeWatchPath, defaultDshHome, + dshCachePath, dshHomeDisplay, dshHomePath, expandHomePath, @@ -56,6 +57,34 @@ describe('dsh path helpers', () => { expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') }) + it.each([ + [undefined, join(homedir(), '.dsh')], + ['', join(homedir(), '.dsh')], + [' ', join(homedir(), '.dsh')], + ['~/env-dsh', join(homedir(), 'env-dsh')], + ['./relative-dsh', resolve('./relative-dsh')], + ] as const)('resolves cache paths with DSH_HOME=%j', (home, expectedHome) => { + vi.stubEnv('DSH_HOME', home) + try { + expect(dshCachePath()).toBe(join(expectedHome, 'cache')) + expect(dshCachePath('models', 'index.json')).toBe(join(expectedHome, 'cache', 'models', 'index.json')) + } finally { + vi.unstubAllEnvs() + } + }) + + it('resolves configured cache homes before the environment', () => { + vi.stubEnv('DSH_HOME', '~/env-dsh') + try { + expect(dshCachePath({ dshHome: '~/explicit-dsh' })).toBe(join(homedir(), 'explicit-dsh', 'cache')) + expect(dshCachePath({ dshHome: './explicit-dsh' }, 'attachments', 'request-images')) + .toBe(resolve('./explicit-dsh/cache/attachments/request-images')) + expect(dshCachePath({}, 'attachments')).toBe(join(homedir(), 'env-dsh', 'cache', 'attachments')) + } finally { + vi.unstubAllEnvs() + } + }) + it('canonicalizes a watcher ancestor while preserving a missing suffix', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-watch-path-')) const target = join(root, 'target') @@ -63,6 +92,7 @@ describe('dsh path helpers', () => { try { await mkdir(target) await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir') + await expect(canonicalizeWatchPath(alias)).resolves.toBe(await realpath(target)) await expect(canonicalizeWatchPath(join(alias, 'later', 'config.yml'))).resolves.toBe( join(await realpath(target), 'later', 'config.yml'), ) diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index 815d2e3d85..87d0ddcc77 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 1e51d0a00a..36f1f4be95 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index bac8bc22a8..31d5770858 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index ff72365c3c..1de1a53bd5 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/package-manifest/package.json b/packages/util/package-manifest/package.json index fdb42e767c..85b8a0b1ff 100644 --- a/packages/util/package-manifest/package.json +++ b/packages/util/package-manifest/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-package-manifest", "description": "Shared type declarations for package.json.dsh configuration fields", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 6c0746c340..a191aa8376 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 9dbeb4f71c..c1cbd2bae2 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index 9018389817..b46eed22cc 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 2a83879277..a8dca334e3 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index d95a2943c4..b3fe173fad 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index d6d341279e..85ab4871d8 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index f3263acc41..26eeb35a32 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index c4300449bc..85bedd66d4 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 67f36184e9..1e5cc82fc5 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 2a111c79c1..58fa29784a 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 571995f31b..e94244a5b8 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index a279af4632..ca075c0b3d 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 1ac7810895..5f22369daa 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 0a49b53eb1..bf6b1e1c60 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index d36174b94f..1d41b5c216 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -135,6 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. for (const event of session.snapshotEvents().filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index d56e7bb1f8..51507b52c1 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 3b73206524..8b958f9ceb 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 46770c874f..35df2dc687 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.5-rc.1", + "version": "0.1.5-rc.2", "publishConfig": { "access": "public" }, diff --git a/patches/@electron__osx-sign@1.3.3.patch b/patches/@electron__osx-sign@1.3.3.patch new file mode 100644 index 0000000000..791434536c --- /dev/null +++ b/patches/@electron__osx-sign@1.3.3.patch @@ -0,0 +1,26 @@ +diff --git a/dist/cjs/util.js b/dist/cjs/util.js +index 77e64b56872bc65bcb0f60ecfbec84eb3da02c6f..8d5b81eea636c3f1672cfe2c66602078bda1e847 100644 +--- a/dist/cjs/util.js ++++ b/dist/cjs/util.js +@@ -153,7 +153,7 @@ async function walkAsync(dirPath) { + const children = await fs.readdir(dirPath); + return await Promise.all(children.map(async (child) => { + const filePath = path.resolve(dirPath, child); +- const stat = await fs.stat(filePath); ++ const stat = await fs.lstat(filePath); + if (stat.isFile()) { + switch (path.extname(filePath)) { + case '.cstemp': // Temporary file generated from past codesign +diff --git a/dist/esm/util.js b/dist/esm/util.js +index d3f66d54b1a88e7410d78affe17de87302db02a3..e86b066d1d21628908ecc848f5bb29a7598605a7 100644 +--- a/dist/esm/util.js ++++ b/dist/esm/util.js +@@ -117,7 +117,7 @@ export async function walkAsync(dirPath) { + const children = await fs.readdir(dirPath); + return await Promise.all(children.map(async (child) => { + const filePath = path.resolve(dirPath, child); +- const stat = await fs.stat(filePath); ++ const stat = await fs.lstat(filePath); + if (stat.isFile()) { + switch (path.extname(filePath)) { + case '.cstemp': // Temporary file generated from past codesign diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc061b2f4b..f04c52ac4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@deepseek-ai/schemastery': link:vendor/schemastery patchedDependencies: + '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 '@yao-pkg/pkg@6.21.0': 28edd2180c36691c481522ef81f6f6614505f45e491aad542ac4663d4e6b3ff8 node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0 @@ -528,9 +529,6 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.3.1 - msgpackr: - specifier: 2.0.4 - version: 2.0.4 pnpm: specifier: 11.7.0 version: 11.7.0 @@ -1026,6 +1024,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -1050,6 +1051,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -1166,9 +1170,15 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../../host/directory-picker + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -1261,6 +1271,9 @@ importers: '@deepseek-ai/dsh-package-manifest': specifier: workspace:^ version: link:../../util/package-manifest + chokidar: + specifier: 4.0.3 + version: 4.0.3 js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -1301,9 +1314,6 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 - chokidar: - specifier: 4.0.3 - version: 4.0.3 packages/boot/cmdline: devDependencies: @@ -3297,6 +3307,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -3649,6 +3662,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -3691,6 +3707,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../../test-support/remote-mock '@deepseek-ai/dsh-util-values': specifier: workspace:^ version: link:../../util/values @@ -7486,6 +7505,9 @@ importers: packages/plan/plan-mode: dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand zod: specifier: ^4.4.3 version: 4.4.3 @@ -10074,6 +10096,9 @@ importers: '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 vitest: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(happy-dom@20.11.6)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -10081,6 +10106,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -10090,9 +10127,21 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-base': + specifier: workspace:^ + version: link:../../bundle/base '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-hmr': + specifier: workspace:^ + version: link:../../client/hmr + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store @@ -10117,15 +10166,30 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-web': + specifier: workspace:^ + version: link:../../client/web + '@deepseek-ai/dsh-remote-mock': + specifier: workspace:^ + version: link:../remote-mock '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-web-app': + specifier: workspace:^ + version: link:../../bundle/web-app + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -10198,6 +10262,28 @@ importers: specifier: workspace:^ version: link:../../core/session + packages/test-support/remote-mock: + dependencies: + '@vitest/spy': + specifier: ^4.1.8 + version: 4.1.8 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-settings-controller': + specifier: workspace:^ + version: link:../../api/settings-controller + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/test-support/session-snapshot: dependencies: '@agentclientprotocol/sdk': @@ -12968,36 +13054,6 @@ packages: resolution: {integrity: sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==} hasBin: true - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} - cpu: [arm64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} - cpu: [x64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} - cpu: [arm64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} - cpu: [arm] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} - cpu: [x64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} - cpu: [x64] - os: [win32] - '@napi-rs/canvas-android-arm64@1.0.8': resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} engines: {node: '>= 10'} @@ -16682,13 +16738,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msgpackr-extract@3.0.4: - resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} - hasBin: true - - msgpackr@2.0.4: - resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} - multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} @@ -16786,10 +16835,6 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} - hasBin: true - node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -19033,7 +19078,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/osx-sign@1.3.3': + '@electron/osx-sign@1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861)': dependencies: compare-version: 0.1.2 debug: 4.4.3(supports-color@9.4.0) @@ -19785,24 +19830,6 @@ snapshots: - supports-color - zod - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - optional: true - '@napi-rs/canvas-android-arm64@1.0.8': optional: true @@ -21271,7 +21298,7 @@ snapshots: '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 - '@electron/osx-sign': 1.3.3 + '@electron/osx-sign': 1.3.3(patch_hash=280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861) '@electron/rebuild': 4.2.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 @@ -23715,22 +23742,6 @@ snapshots: ms@2.1.3: {} - msgpackr-extract@3.0.4: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true - - msgpackr@2.0.4: - optionalDependencies: - msgpackr-extract: 3.0.4 - multistream@4.1.0: dependencies: once: 1.4.0 @@ -23817,11 +23828,6 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-gyp-build-optional-packages@5.2.2: - dependencies: - detect-libc: 2.1.2 - optional: true - node-gyp@12.4.0: dependencies: env-paths: 2.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 68eff9cd1c..3e8cf9db0e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,10 +14,6 @@ packages: # closure is what the exe bundles and what the Python runtime distributes. - python/sdk-runtime -# Vendored framework packages keep their upstream semver ranges, while local -# builds must resolve those matching names to this workspace's pinned sources. -linkWorkspacePackages: true - overrides: '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' '@deepseek-ai/schemastery': 'link:vendor/schemastery' @@ -84,5 +80,6 @@ minimumReleaseAgeExclude: - '@openai/codex@0.153.4||0.153.4-darwin-arm64||0.153.4-darwin-x64||0.153.4-linux-arm64||0.153.4-linux-x64||0.153.4-win32-arm64||0.153.4-win32-x64' patchedDependencies: + '@electron/osx-sign@1.3.3': patches/@electron__osx-sign@1.3.3.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 0cb760ac38..a479d3b412 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md -README.md: fb7478f305015e60dafd23861ab6fd91f10757f3 -README.zh.md: 8acd09e6e349190716bad06cf5d4264db06f6197 +README.md: ce733b8f293e974ec051e600c187e0741117ab46 +README.zh.md: 23a042006c38425a83347322fa63f0fafb319d0d diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index fb7478f305..ce733b8f29 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -31,6 +31,8 @@ External profile management uses `dsh plugin --profile ...`. That command ## Build and distribution +Production deployment permits unused workspace patches for packages outside the runtime closure; patches for included packages must still apply successfully. This exception is confined to the deploy command; repository installation still rejects unused patches. + From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. The installed-wheel smoke creates a clean virtual environment outside the checkout, proves distribution and executable provenance, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 8acd09e6e3..23a042006c 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -31,6 +31,8 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python ## 构建与分发 +生产部署允许工作区中不属于运行时闭包的补丁保持未使用;闭包内包的补丁仍必须成功应用。此例外仅用于部署命令,仓库安装仍拒绝未使用的补丁。 + 在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/scripts/browser-bundled-externals.spec.ts b/scripts/browser-bundled-externals.spec.ts index b4d95cf96d..cadc8fb6ae 100644 --- a/scripts/browser-bundled-externals.spec.ts +++ b/scripts/browser-bundled-externals.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -78,7 +78,7 @@ describe('browser dependency discovery', () => { await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config') }) - it('follows shell workspace aliases, CSS assets and lazy imports without writing output', async () => { + it.each([false, true])('follows shell aliases, CSS and lazy imports without writing output (symlinked root: %s)', async (linked) => { const root = fixture() library(root, 'shell-lib') library(root, 'lazy-lib') @@ -104,7 +104,13 @@ describe('browser dependency discovery', () => { }`) write(root, 'apps/web/dist/sentinel.txt', 'untouched') - expect(await browserBundledExternals(root)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib'])) + const scanRoot = linked ? join(fixture(), 'linked') : root + if (linked) symlinkSync(root, scanRoot, 'junction') + try { + expect(await browserBundledExternals(scanRoot)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib'])) + } finally { + if (linked) unlinkSync(scanRoot) + } expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched') expect(existsSync(join(app, 'dist/index.html'))).toBe(false) expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false) diff --git a/scripts/browser-bundled-externals.ts b/scripts/browser-bundled-externals.ts index 85053be30f..9139f6b4c2 100644 --- a/scripts/browser-bundled-externals.ts +++ b/scripts/browser-bundled-externals.ts @@ -1,6 +1,6 @@ /** Resolve direct third-party browser inputs through the shipping build configurations, without emitting files. */ -import { globSync, readFileSync } from 'node:fs' +import { globSync, readFileSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -159,10 +159,12 @@ async function collectShell( /** * Direct third-party packages resolved by published browser builds. - * @param root - Repository root with installed build dependencies; lib/ is not required. + * @param root - Repository root, possibly symlinked, with installed build dependencies; lib/ is not required. * @returns Names of distributed browser inputs, excluding workspace packages and erased types. */ export async function browserBundledExternals(root: string): Promise> { + // Vite resolves HTML inputs to real paths, so its root must use the same spelling. + root = realpathSync(root) const manifests = new Map() for (const glob of ['packages/*/*/package.json', 'vendor/*/package.json']) { for (const path of globSync(glob, { cwd: root }).sort()) { diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index 0fe43528f2..b0c937a9eb 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -60,6 +60,9 @@ describe('Python runtime executable builder CLI', () => { expect(result.status).toBe(0) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`) expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`) + const deploy = result.stdout.split('\n').find(line => line.includes(' --filter dsh-python-runtime-closure deploy')) + expect(deploy).toContain('--prod --config.allow-unused-patches=true') + expect(result.stdout.split('--config.allow-unused-patches=true')).toHaveLength(2) expect(result.stdout).not.toContain(resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs')) expect(result.stdout).toContain('"bin":"runtime-bootstrap.mjs"') expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs exec pkg`) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index f58a526e64..b615666c31 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -292,6 +292,8 @@ class SingleExeBuild { 'deploy', '--legacy', '--prod', + // Production deployment omits workspace tooling such as Electron's patched signer. + '--config.allow-unused-patches=true', '--config.node-linker=hoisted', '--config.auto-install-peers=false', '--config.link-workspace-packages=true', diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 26d040eac2..5a7cb17fd8 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -258,7 +258,8 @@ describe('CI workflow', () => { isRecord(step) && step.name === 'Configure persistent pnpm store' && typeof step.run === 'string' )) expect(serialStore).toBeDefined() - expect(serialStore!.run).toContain('F:\\.pnpm-store') + expect(serialStore!.run).toContain('[IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)') + expect(serialStore!.run).toContain("'.pnpm-store'") const serialInstall = serialSteps.find((step): step is Record & { run: string } => ( isRecord(step) && step.name === 'Install (immutable)' && typeof step.run === 'string' )) diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index aae2fcf2cf..b95277a6fc 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -78,6 +78,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/test-support/client-runtime': 'Browser-side test infrastructure.', 'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.', 'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.', + 'packages/test-support/remote-mock': 'Browser-side test infrastructure; mounts nothing into a product composition.', 'packages/typert/generator': 'Build-time generator run outside any agent runtime.', 'packages/typert/protocol': 'Compiler-independent protocol declarations.', 'packages/util/atomic-write': 'Zero-dependency filesystem write utility.', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 95d51cad28..85bf102604 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -382,7 +382,8 @@ export const LINK_MAP: Readonly> = { SaveFileStreamAttachment: 'attachment.md', ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', - ImageRequestPolicy: 'attachment.md', + ImageRequestTarget: 'attachment.md', + ProjectedDimensions: 'attachment.md', PromptContentPart: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index 4aecd9e985..5ad4653d4e 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -30,7 +30,7 @@ const profiles = { test: { count: 84, indexes: [0, 3, 4, 5], - sha256: '7800bf57f0d689a5623e4ad60315b10173158fb9cf988d3f005cb115f2245691', + sha256: '7e4e1ed0cfca06ac94eeb26081195b5f753e7527775d5f5b95ccfefac9d2b137', }, } as const satisfies Record diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 1141067ad2..8cb4098f17 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -254,6 +254,99 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + } }, 90_000) + it('allows Session history reads only in tests or with existing-call waivers', async () => { + const suffix = randomUUID() + const configPath = await writeContractConfig(suffix) + const exampleRoot = `examples/oxlint-contract-${suffix}` + const examplePath = `${exampleRoot}/tests/reads.ts` + const testPaths = [ + `packages/core/session/tests/oxlint-contract-${suffix}.ts`, + `apps/cli/tests/oxlint-contract-${suffix}.ts`, + examplePath, + `scripts/oxlint-contract-${suffix}.spec.ts`, + ] + const productionPaths = [ + `packages/core/session/src/oxlint-contract-${suffix}.ts`, + `scripts/oxlint-contract-${suffix}.ts`, + ] + const paths = [...testPaths, ...productionPaths] + const reads = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session' + +export function reads(session: Session): void { + session.snapshotEvents() + session.eventAt(SessionSeq(0)) + session.ownEvents() +} +` + const existing = `import { Session, SessionSeq } from '@deepseek-ai/dsh-session' + +export function reads(session: Session): void { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + session.snapshotEvents() + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + session.eventAt(SessionSeq(0)) + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + session.ownEvents() +} +` + const unrelated = ` +/** @deprecated Use the replacement API. */ +function oldApi(): void {} + +export function unrelatedRead(): void { + oldApi() +} +` + + try { + await mkdir(join(repositoryRoot, exampleRoot, 'tests'), { recursive: true }) + await writeFile(join(repositoryRoot, exampleRoot, 'tsconfig.json'), JSON.stringify({ + extends: '../../tsconfig.base.json', + include: ['tests/**/*.ts'], + })) + await Promise.all([ + ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads)), + ...productionPaths.map(path => writeFile(join(repositoryRoot, path), existing)), + ]) + const args = ['--config', relative(repositoryRoot, configPath), '--format', 'unix', ...paths] + const allowed = runRepositoryOxlint(args) + expect(allowed.error).toBeUndefined() + expect(allowed.signal).toBeNull() + expect(allowed.status, normalizedOutput(allowed)).toBe(0) + + await Promise.all([ + ...testPaths.map(path => writeFile(join(repositoryRoot, path), reads + unrelated)), + ...productionPaths.map(path => writeFile(join(repositoryRoot, path), reads)), + ]) + const rejected = runRepositoryOxlint(args) + const output = normalizedOutput(rejected) + expect(rejected.error).toBeUndefined() + expect(rejected.signal).toBeNull() + expect(rejected.status, output).toBe(1) + const diagnostics = output.split('\n').filter(line => /:\d+:\d+: `\w+` is deprecated\./.test(line)) + for (const path of testPaths) { + const reported = diagnostics.filter(line => line.startsWith(`${path}:`)) + expect(reported, output).toHaveLength(1) + expect(reported[0]).toContain('`oldApi` is deprecated') + } + for (const path of productionPaths) { + expect(diagnostics.filter(line => line.startsWith(`${path}:`)), output).toHaveLength(3) + } + for (const method of ['snapshotEvents', 'eventAt', 'ownEvents', 'oldApi']) { + expect(output).toContain(`\`${method}\` is deprecated`) + } + expect(output).toContain( + 'See the [Agent Note](../../../../.agents/notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md).', + ) + } finally { + await Promise.all([ + ...paths.filter(path => path !== examplePath).map(path => rm(join(repositoryRoot, path), { force: true })), + rm(join(repositoryRoot, exampleRoot), { recursive: true, force: true }), + rm(configPath, { force: true }), + ]) + } + }, 90_000) + it('accepts an ignored-only staged selection', () => { const result = runOxlint([ '--fix', diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 5a73201bf0..ec75d2938b 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -222,15 +222,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [ { id: 'vendor-readme-preamble', file: 'vendor/README.md', - find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.', - replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.', - expect: 1, - }, - { - id: 'vendor-readme-schemastery-note', - file: 'vendor/README.md', - find: 'whose lazy `require(\'cosmokit\')` can race', - replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race', + find: 'All vendored packages keep their **original npm names** (they are resolved through pnpm workspaces) and are marked `private: true` — they are never published from this repo.', + replace: 'All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges.', expect: 1, }, { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index a5ad767c32..de9ca46d88 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -213,7 +213,7 @@ describe('gate graph validation', () => { 'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config', - 'runtime-closure', 'vendored-links', + 'runtime-closure', ]) expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ workers: 4, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3918c87d38..9dac9e6560 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -286,7 +286,6 @@ export function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates(), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('vendored-links', 'verify-vendored-links', { label: 'vendored links' }), ] case 'doc-sync': return docSyncLeafGates() diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 43d0ec2ccb..139be0f756 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -918,6 +918,8 @@ def smoke_sdk_live() -> None: "Use a tool to read that same file and copy its exact current content to the " "new receipt path below, without changing the source file. " "Preserve every byte; do not add a newline or byte-order mark. " + f"{LIVE_API_SENTINEL} is only the completion acknowledgement, " + "not a claim about the source or receipt contents. " f"Then reply with exactly {LIVE_API_SENTINEL}.\n{receipt}" ) verified = harness.run(verify_prompt, session_id=session_id) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e7436d98be..86aba302e1 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1003,9 +1003,14 @@ }, { "doc": "docs/subsystems/attachment.md", - "symbol": "ImageRequestPolicy", + "symbol": "ImageRequestTarget", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ProjectedDimensions", + "source": "packages/attachment/attachment/src/request-projection.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "RequestImageAttachment", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index b15f971682..df43fada87 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -177,6 +177,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/loader-smoke': { kind: 'none', reason: 'The test harness submits an ordinary user task but delegates prompt and tool composition to the loaded tree.' }, 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, + 'packages/test-support/remote-mock': { kind: 'none', reason: 'Browser-side test infrastructure (Remote endpoint mock); registers nothing model-facing.' }, 'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' }, 'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' }, 'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' }, diff --git a/scripts/verify-vendored-links.ts b/scripts/verify-vendored-links.ts deleted file mode 100644 index 93b8390157..0000000000 --- a/scripts/verify-vendored-links.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Verify that pnpm-lock.yaml resolves every vendored package name to its - * workspace `link:` — never a registry copy. `linkWorkspacePackages: true` - * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the - * pinned vendored sources; a registry copy of the same name coexisting with - * the vendored one silently forks the framework layer (vendor/README.md). - */ -import { readdir, readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import * as yaml from 'js-yaml' - -const root = resolve(import.meta.dirname, '..') - -async function vendoredNames(): Promise> { - const names = new Set() - for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) { - if (!entry.isDirectory()) continue - let manifest: { name?: string } - try { - manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string } - } catch { - continue // not a package directory (e.g. vendor/README.md siblings) - } - if (manifest.name !== undefined) names.add(manifest.name) - } - return names -} - -interface Lockfile { - importers?: Record> - packages?: Record - snapshots?: Record -} - -const names = await vendoredNames() -if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/') -const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile - -const violations: string[] = [] - -// Importer resolutions: every dependency entry naming a vendored package must -// resolve to a link:, or the build silently uses a registry copy. -for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) { - for (const [section, dependencies] of Object.entries(sections)) { - if (typeof dependencies !== 'object' || dependencies === null) continue - for (const [dependency, entry] of Object.entries(dependencies as Record)) { - if (!names.has(dependency)) continue - const version = entry.version ?? '' - if (!version.startsWith('link:')) { - violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`) - } - } - } -} - -// Package/snapshot keys: a registry copy materializes as a `@` -// key; vendored names must never appear there at all. -for (const section of ['packages', 'snapshots'] as const) { - for (const key of Object.keys(lockfile[section] ?? {})) { - const atIndex = key.lastIndexOf('@') - if (atIndex <= 0) continue - const packageName = key.slice(0, atIndex) - if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`) - } -} - -if (violations.length > 0) { - console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`) - for (const violation of violations) console.error(` - ${violation}`) - process.exit(1) -} -console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`) diff --git a/snapshots/session/ptc-turn/cordis.snapshot.yml b/snapshots/session/ptc-turn/cordis.snapshot.yml index 5ef66b0f1e..72ac169e3c 100644 --- a/snapshots/session/ptc-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-turn/cordis.snapshot.yml @@ -1,5 +1,9 @@ # Keyless PTC mode combines the runtime/registry changes with the # DeepSeek-to-replay swap in one profile patch. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/snapshots/session/ptc-turn/cordis.yml b/snapshots/session/ptc-turn/cordis.yml index 1ff5387957..a9f85a8e1c 100644 --- a/snapshots/session/ptc-turn/cordis.yml +++ b/snapshots/session/ptc-turn/cordis.yml @@ -1,6 +1,10 @@ # PTC mode adds `ctx.codeRuntime` and changes the registry to one wire tool, # `run_code`, plus its generated TypeScript SDK prompt. The demo and snapshot # recorder apply this profile patch; replay applies its sibling patch. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' config: diff --git a/snapshots/session/ptc-turn/system-prompt.expected.md b/snapshots/session/ptc-turn/system-prompt.expected.md index dd648445e1..589b935ac2 100644 --- a/snapshots/session/ptc-turn/system-prompt.expected.md +++ b/snapshots/session/ptc-turn/system-prompt.expected.md @@ -191,6 +191,10 @@ interface ToolArgsMap { /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ prompt: string; } & Record; + /** Echo template text such as {{item}}, {{model}}, or {{ item }} without substitution. */ + template_echo: { + text: string; + } & Record; /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ todo_write: { /** The COMPLETE task list, replacing any previous list. */ @@ -466,6 +470,7 @@ interface ToolOutputMap { runId: string; output: JsonValue[]; }; + template_echo: string; todo_write: { todos: ({ content: string; diff --git a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml index 63cd3c497f..f8e5fa3228 100644 --- a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml +++ b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml @@ -1,5 +1,9 @@ # Keyless replay counterpart of ptc-workspace-context.cordis.yml. It adds # PTC mode to the default filesystem suite and swaps in replay. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true diff --git a/snapshots/session/ptc-workspace-context/cordis.yml b/snapshots/session/ptc-workspace-context/cordis.yml index ac55e8c923..d8aa490ecc 100644 --- a/snapshots/session/ptc-workspace-context/cordis.yml +++ b/snapshots/session/ptc-workspace-context/cordis.yml @@ -1,5 +1,9 @@ # PTC mode agent-instructions snapshot recording overlay. The default filesystem # tools trigger nested instruction discovery after a read. +- insert: + - id: literal-sdk + name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' + - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' config: diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index 8038eab574..89f53fe375 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -28,9 +28,7 @@ - img - text: "Failed Bash Error: tool call aborted before dispatch" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 5310a3d45c..9dc7e9aac3 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -116,9 +116,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 443554c243..b2461b78c1 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -46,9 +46,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index 3285e3c511..f8ce7b7562 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -38,9 +38,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 443554c243..b2461b78c1 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -46,9 +46,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 3285e3c511..f8ce7b7562 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -38,9 +38,7 @@ - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/feedback-release/feedback-release.expected.json b/snapshots/web/feedback-release/feedback-release.expected.json index a156f96066..994953b4ef 100644 --- a/snapshots/web/feedback-release/feedback-release.expected.json +++ b/snapshots/web/feedback-release/feedback-release.expected.json @@ -11,7 +11,9 @@ }, { "type": "feedback/message-put", - "rating": "positive" + "rating": "positive", + "note": "Clear and complete.", + "category": "instruction-following" }, { "type": "feedback/message-put", diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index f88f17d869..cce686501f 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -40,9 +40,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high": diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 9f05223711..e70824c958 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -50,9 +50,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 9e224849aa..7be36de733 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -34,9 +34,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 87fac187c2..44a643e40c 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -228,9 +228,7 @@ - button "Back to bottom": - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 582bae956e..8eb56534fe 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -116,9 +116,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md b/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md index 13d915959d..96bf4bd119 100644 --- a/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md +++ b/snapshots/web/lifecycle-chrome/command-menu-fuzzy.expected.md @@ -1,3 +1,3 @@ - listbox "Trigger suggestions": - text: Commands - - option "compact Compact older conversation history" [selected] + - option "Compact Compact older conversation history" [selected] diff --git a/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md b/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md index c093f3c096..23397f818c 100644 --- a/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md +++ b/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md @@ -1,9 +1,11 @@ - listbox "触发候选建议": + - text: 添加 + - option "文件 file" [selected] + - option "目标 goal 设置或查看长期任务目标" + - option "计划 plan 进入或退出计划模式" + - option "反馈 feedback 发送关于当前会话的反馈" - text: 指令 - - option "compact 压缩以上对话内容" [selected] - - option "export 将当前会话内容导出为 ZIP" - - option "feedback 发送关于当前会话的反馈" - - option "goal 设置或查看长期任务目标" - - option "permission 切换权限预设(沙箱模式与审批策略)" - - option "plan 进入或退出计划模式" - - option "model 选择本会话使用的模型" + - option "压缩 compact 压缩以上对话内容" + - option "权限 permission 切换权限预设(沙箱模式与审批策略)" + - option "模型 model 选择本会话使用的模型" + - option "下载日志 export 将当前会话内容导出为 ZIP" diff --git a/snapshots/web/lifecycle-chrome/command-menu.expected.md b/snapshots/web/lifecycle-chrome/command-menu.expected.md index ed7e837f3f..c720fee9e1 100644 --- a/snapshots/web/lifecycle-chrome/command-menu.expected.md +++ b/snapshots/web/lifecycle-chrome/command-menu.expected.md @@ -1,9 +1,11 @@ - listbox "Trigger suggestions": + - text: Add + - option "File" [selected] + - option "Goal Set or view the goal for a long-running task" + - option "Plan Enter or leave plan mode" + - option "Feedback Record feedback about this session" - text: Commands - - option "compact Compact older conversation history" [selected] - - option "export Download this Session log as a ZIP archive" - - option "feedback record feedback about this session" - - option "goal set or view the goal for a long-running task" - - option "permission Switch the permission preset (sandbox mode + approval policy)" - - option "plan Enter or leave plan mode" - - option "model Select the model for this conversation" + - option "Compact Compact older conversation history" + - option "Permission Switch the permission preset (sandbox mode + approval policy)" + - option "Model Select the model for this conversation" + - option "Export Download this Session log as a ZIP archive" diff --git a/snapshots/web/lifecycle-chrome/connection-error.expected.md b/snapshots/web/lifecycle-chrome/connection-error.expected.md index 31046ddf7b..d8526e9a57 100644 --- a/snapshots/web/lifecycle-chrome/connection-error.expected.md +++ b/snapshots/web/lifecycle-chrome/connection-error.expected.md @@ -1,4 +1,4 @@ - button "Settings": - img - text: Settings -- button "Reconnecting automatically, reconnect now": Reconnect now +- button "Reconnecting, reconnect now": Reconnecting diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index aa7c6a7728..7c5a8b7f98 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -31,9 +31,7 @@ - img - textbox "Describe what you want to build, / commands, @ files or sessions": - paragraph -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index bda1b957d8..da4ae2482b 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -30,9 +30,7 @@ - text: Standard mode - img - textbox "Describe what you want to build, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index 6bc4d76c3a..36906677cc 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -42,9 +42,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 561edd89e4..513443a4c2 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -34,9 +34,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 7eaeac6b7a..8cf1e25180 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -39,9 +39,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index ee6545830b..9abc3ddf5a 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -35,9 +35,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index 72a002e385..ada2676413 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -25,9 +25,7 @@ - text: This turn failedAPI key is invalid - code: AUTH - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 02387b88de..b5975fd0b5 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -24,9 +24,7 @@ - paragraph: partial - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 388c0cb61f..9d680e2941 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -27,9 +27,7 @@ - text: This turn failedupstream 503 - code: SERVER - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 69dfa8ead7..91bff98c7d 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -44,9 +44,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 771ea4e6ef..f7fe087126 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -34,9 +34,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 52788933b1..62cb3ead70 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -25,9 +25,7 @@ - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index e628800c81..4a80390ff4 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -68,9 +68,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index fecd158d57..971a0cb5ff 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -39,9 +39,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 122a6b3671..0e4b01bcdb 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -60,9 +60,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index b8d17dad82..b5eb059952 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -40,9 +40,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index dc02a64edc..58c1dea045 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -91,9 +91,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 1b55072dc5..4c54c3f557 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -61,9 +61,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index e0c69dac00..b228e5d6c9 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -52,9 +52,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 04cceb554a..8e416c1bf2 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -34,9 +34,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index a6a85d1e17..55102c8332 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -31,9 +31,7 @@ - listitem: Which color do you prefer? - button "Inspect" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index 66f3706af5..a3e655e8b9 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -15,5 +15,5 @@ - button "Next question" [disabled]: - img - status - - button "Skip this question" + - button "Skip" - button "Submit" diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 1c7109702d..b86e6c0223 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -15,5 +15,5 @@ - button "Next question" [disabled]: - img - status - - button "Skip this question" + - button "Skip" - button "Submit" [disabled] diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index e995bbf5eb..2586c98553 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -25,9 +25,7 @@ - status: Deep diving... - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 4078e36661..667235bee2 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -41,9 +41,7 @@ - button "Cancel editing": - img - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/failed.expected.md b/snapshots/web/queue-actions/failed.expected.md index 02c1fa881b..044a159013 100644 --- a/snapshots/web/queue-actions/failed.expected.md +++ b/snapshots/web/queue-actions/failed.expected.md @@ -1,9 +1,7 @@ - alert: Queue submission failed (session/agent-busy) - textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue submission to retry -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 2ffd53409b..eeea3d4726 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -41,9 +41,7 @@ - img - button "2 queued messages" - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 300ae138f4..37f6e40af0 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -57,9 +57,7 @@ - button "Steer queued message" [disabled]: - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 3a0f021b14..8de751f99d 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -53,9 +53,7 @@ - button "Steer queued message" [disabled]: - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index eeabc5d6b2..0d1b88c45f 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -34,9 +34,7 @@ - button "Steer queued message" [disabled]: - img - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 67057867a6..5f9dd0ffa0 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -34,9 +34,7 @@ - button "Steer queued message": - img - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index b0c1e9fd8e..759df4e6b1 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -78,9 +78,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 0e59f304d4..3b18b54846 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -34,9 +34,7 @@ - button "Steer queued message": - img - textbox "Cmd/Ctrl+Enter steers all queued messages" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 2d563036c5..4937ff0bcf 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -60,9 +60,7 @@ - img - text: permission preset read-only - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index e4d038c87d..1ce614ef2f 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -64,9 +64,7 @@ - text: "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 0f1cad4f7b..f23b05cfbb 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -58,9 +58,7 @@ - img - text: Context injection AGENTS.md - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 133ef8d008..1061d02a91 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -40,9 +40,7 @@ - img - text: Context injection AGENTS.md - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 3365c8f244..f25b8336ff 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -53,9 +53,7 @@ - text: Ran for {{duration}} - text: {{date}} {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 5d3e0929a4..16fec2b6ba 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -47,5 +47,5 @@ - button "Next question" [disabled]: - img - status - - button "Skip this question" + - button "Skip" - button "Submit" [disabled] diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 5341248e66..c99e517377 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -45,9 +45,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 8eafc70564..ab98340d50 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -37,9 +37,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 598cf5a252..52261c0331 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -77,9 +77,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment" [disabled]: +- button "Add files or run commands": - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index d855468c64..ffd5e19072 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -65,9 +65,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment" [disabled]: +- button "Add files or run commands": - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 5e1f10df42..e7b2a6f3ab 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -35,9 +35,7 @@ - button "Steer queued message": - img - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] -- button "Commands" [disabled]: - - img -- button "Add attachment" [disabled]: +- button "Add files or run commands" [disabled]: - img - 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index fd32496955..f8882db10c 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -37,9 +37,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 5d89f06e46..498e51b0c5 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -50,9 +50,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index ecb2da574e..50abcf5bc2 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -34,9 +34,7 @@ - paragraph: partial - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 839f26e361..71992ccd0f 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -36,9 +36,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 1c8edb386d..478dd89dd3 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -37,9 +37,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 96fd77fbb6..c75135da5f 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -42,9 +42,7 @@ - text: Ran for {{duration}} - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Commands": - - img -- button "Add attachment": +- button "Add files or run commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": diff --git a/tsconfig.base.json b/tsconfig.base.json index 612c4b456d..290df8cbf5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -253,6 +253,9 @@ // util/ folders are role-named without the util- prefix their npm names carry, // so these aliases stay hand-written like dsh-util-crypto above. "@deepseek-ai/dsh-util-time": ["./packages/util/time/src"], + // session-log-export lives under session-query/, so the generated aliases map only its + // bare name; the browser half needs this explicit subpath for source-mode dynamic imports. + "@deepseek-ai/dsh-session-log-export/client": ["./packages/session-query/session-log-export/src/client"], // BEGIN generated package aliases — pnpm run gen-tsconfig-paths "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], @@ -341,6 +344,7 @@ "@deepseek-ai/dsh-plugin-manager": ["./packages/boot/plugin-manager/src"], "@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"], "@deepseek-ai/dsh-pwsh-sandbox": ["./packages/shell/pwsh-sandbox/src"], + "@deepseek-ai/dsh-remote-mock": ["./packages/test-support/remote-mock/src"], "@deepseek-ai/dsh-repeat-tool-reminder": ["./packages/guard/repeat-tool-reminder/src"], "@deepseek-ai/dsh-sandbox": ["./packages/sandbox/sandbox/src"], "@deepseek-ai/dsh-sandbox-local": ["./packages/sandbox/sandbox-local/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8ffb94127a..494e4f762f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -68,6 +68,7 @@ { "path": "./packages/extensions/cordis-client-runner" }, { "path": "./packages/extensions/ui-cordis" }, { "path": "./packages/test-support/client-runtime" }, + { "path": "./packages/test-support/remote-mock" }, { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-brand-official" }, diff --git a/vendor/README.md b/vendor/README.md index c2b2fc9bde..4faca680cb 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -2,7 +2,7 @@ This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned). -All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory. +All vendored packages use the **`@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`). The manifest table records upstream versions and source commits; each package manifest carries its Harness release version and publication metadata. Repository-owned runtime dependencies use `workspace:^`, so local builds resolve the pinned workspace packages and publication substitutes release ranges. Upstream MIT `LICENSE` files are preserved in each package directory. This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md). @@ -31,19 +31,19 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages. +2. **All `package.json` files**: regenerated for Harness releases with scoped names, release versions, publication metadata, precise bundled-runtime and `lib/types/**/*.d.ts` / `.d.ts.map` file entries, source exports where applicable, and declaration metadata pointing at `lib/types`. Repository-owned dependencies use the workspace protocol. HMR declares `esbuild` as a direct dev dependency for its imported `BuildFailure` type, and Loader requires `node-addon-require-builtin@^0.1.4` to match published app runtimes. 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. -4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. +4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. -6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. `Fiber.update()` returns its `internal/update` waterfall result, allowing Loader callers to await a restart while preserving synchronous config validation. -7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates start candidates concurrently, await every outcome, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, an omitted patch list clears the overlay, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`. -9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`. -10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. -12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. +7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. +9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/app-boot/src/watch-config.ts`. Covered by `packages/boot/app-boot/tests/watch-config.spec.ts`. +10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). +11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned when patches are present; without patches, the returned array retains the entry objects. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`. +12. **`loader/src/config/entry.ts` activation observer**: the detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit consumer audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. -14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. +14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index e9bd85e3e1..c7bf42632e 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -340,7 +340,7 @@ export interface Events { /** Interception hook for a service binding (no core producer). */ 'internal/service'(this: Context, name: string, value: any): void /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */ - 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise): void | Promise + 'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void /** Waterfall: a service is being read through the context proxy. */ 'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any /** Waterfall: a service is being written through the context proxy. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 38a3197e29..6bb1caa7a5 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -730,8 +730,8 @@ export class Fiber { * * @param config — the new raw config; validated before anything restarts. * @param noSave — hint for persistence hooks not to write the change back. - * @returns the update waterfall result; the default restart returns a promise. - * @throws when validation, an update listener, or the restarted plugin fails. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. */ update(config: any, noSave = false) { this.assertActive() @@ -745,7 +745,7 @@ export class Fiber { return } config = this._resolveConfig(config) - return this.context.waterfall(this, 'internal/update', config, noSave, () => { + this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined return this.restart() diff --git a/vendor/hmr/README.md b/vendor/hmr/README.md index eec6ff940f..c9a9cca667 100644 --- a/vendor/hmr/README.md +++ b/vendor/hmr/README.md @@ -8,10 +8,8 @@ application files. Changes to framework-level dependencies fall back to `loader.exit()`, letting the host process restart. Module watches canonicalize their existing base directory before opening -Chokidar. Exact config watches likewise canonicalize the deepest existing -ancestor, then restore any missing suffix. Callbacks and diagnostics retain the -requested absolute filename, while the native backend receives one filesystem -spelling even when Windows supplied an 8.3 alias. +Chokidar, so the native backend receives one filesystem spelling even when +Windows supplied an 8.3 alias. ## Requirements diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index f79d8344dc..e7aed96164 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,10 +1,10 @@ -import { Context, Service, type Plugin } from '@deepseek-ai/cordis' +import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis' import type { Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader' import type { Include } from '@deepseek-ai/cordis-plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' -import { dirname, relative, resolve } from 'node:path' -import { realpath, stat } from 'node:fs/promises' +import { relative, resolve } from 'node:path' +import { realpath } from 'node:fs/promises' import { handleError } from './error.ts' import type {} from '@deepseek-ai/cordis-plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -20,13 +20,6 @@ declare module '@deepseek-ai/cordis' { interface Events { 'hmr/change'(url: string): void 'hmr/reload'(reloads: Map): void - /** - * A watched config-file refresh failed. - * @param filename - Absolute path observed by HMR. - * @param error - Normalized refresh failure. - * @mode parallel - */ - 'hmr/config-update-failed'(filename: string, error: Error): Promise | void } } @@ -52,47 +45,13 @@ interface Reload { runtime?: Plugin.Runtime } -interface ConfigRefresh { - dirty: boolean - running?: Promise -} - -interface ConfigRegistration { - watcher: FSWatcher -} - -async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> { - let root = dirname(filename) - let depth = 0 - while (true) { - try { - if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`) - const canonicalRoot = await realpath(root) - return { - filename: resolve(canonicalRoot, relative(root, filename)), - root: canonicalRoot, - depth, - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const parent = dirname(root) - if (parent === root) throw error - root = parent - depth += 1 - } - } -} - +@Inject('loader') +@Inject('timer') class Hmr extends Service { - static inject = ['loader', 'timer'] - public baseDir: string private internal: ModuleLoader private watcher!: FSWatcher - private readonly configs = new Map() - private readonly configRefreshes = new WeakMap() - private readonly refreshTasks = new Set>() /** * Changes from externals will always trigger a full reload. @@ -124,68 +83,6 @@ class Hmr extends Service { this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl)) } - /** - * Watch one exact config path outside the configured module roots. - * @param filename - Config path, resolved against the HMR base directory. - * @param refresh - Refresh callback run serially on add, change, or unlink. - * @returns an asynchronous disposer once the exact watch is ready. - * @throws when HMR is inactive, the path is already registered, or watcher startup fails. - */ - async registerConfig(filename: string, refresh: () => Promise | void): Promise<() => Promise> { - if (!this.watcher) throw new Error('HMR is not active') - filename = resolve(this.baseDir, filename) - const target = await findWatchRoot(filename) - const watchFilename = target.filename - if (this.configs.has(watchFilename)) throw new Error(`config path already registered: ${filename}`) - - const { root, depth } = target - const watcher = watch(root, { - ...this.config, - cwd: undefined, - depth, - ignored: undefined, - ignoreInitial: false, - }) - const registration = { watcher } - this.configs.set(watchFilename, registration) - const onChange = (path: string) => { - const observed = resolve(path) - if (observed !== filename && observed !== watchFilename) return - this.refreshConfig(registration, filename, refresh) - } - watcher.on('add', onChange) - watcher.on('change', onChange) - watcher.on('unlink', onChange) - - const ready = Promise.withResolvers() - let readyState: 'pending' | 'resolved' | 'rejected' = 'pending' - watcher.once('ready', () => { - readyState = 'resolved' - ready.resolve() - }) - watcher.on('error', (error) => { - if (readyState === 'pending') { - readyState = 'rejected' - ready.reject(error) - } else { - this.ctx.logger.warn(error) - } - }) - - try { - await ready.promise - return this.ctx.effect(() => async () => { - if (this.configs.get(watchFilename) === registration) this.configs.delete(watchFilename) - await watcher.close() - await this.configRefreshes.get(registration)?.running - }, 'hmr.registerConfig()') - } catch (error) { - this.configs.delete(watchFilename) - await watcher.close() - throw error - } - } - /** * Resolve a module specifier to a URL, compatible with Node 22-24. */ @@ -197,12 +94,7 @@ class Hmr extends Service { } async* [Service.init]() { - yield async () => { - await this.watcher?.close() - await Promise.allSettled([...this.configs.values()].map(registration => registration.watcher.close())) - this.configs.clear() - await Promise.allSettled([...this.refreshTasks]) - } + yield () => this.watcher?.close() const { loader } = this.ctx const { root, ignored } = this.config @@ -229,31 +121,15 @@ class Hmr extends Service { ...this.config, cwd: watchBaseDir, ignored: path => match(relative(watchBaseDir, path)), - // The initial scan re-announces files the boot just consumed: an `add` - // for a config file refreshes an include whose initial apply may still - // be in flight, and a failing apply then rolls this plugin back while - // the scan-triggered refresh waits on that apply — a teardown deadlock - // that strands boot without a diagnostic. Only events after the scan - // matter here; `registerConfig` keeps its own initial scan because a - // user patch layer present at registration must apply once. ignoreInitial: true, }) const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) - const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { - this.ctx.logger.debug('%s detected at %C', kind, path) + this.watcher.on('change', async (path) => { + this.ctx.logger.debug('change detected at %C', path) const filename = resolve(watchBaseDir, path) const configuredFilename = resolve(this.baseDir, path) - // Config reload: the file is a loader config file (e.g. cordis.yml). - for (const entry of loader.entries()) { - const include = entry.subtree as Include | undefined - if (include?.filename !== filename && include?.filename !== configuredFilename) continue - this.refreshConfig(include, include.filename, () => include.refresh()) - return - } - - if (kind !== 'change') return const url = pathToFileURL(filename).href // Full reload: the changed file is part of the framework @@ -267,11 +143,15 @@ class Hmr extends Service { return partialReload() } + for (const entry of loader.entries()) { + const include = entry.subtree as Include | undefined + if (include?.filename !== filename && include?.filename !== configuredFilename) continue + await include.refresh() + return + } + this.ctx.emit('hmr/change', url) - } - this.watcher.on('add', path => onChange('add', path)) - this.watcher.on('change', path => onChange('change', path)) - this.watcher.on('unlink', path => onChange('unlink', path)) + }) const ready = Promise.withResolvers() let readyState: 'pending' | 'resolved' | 'rejected' = root.length === 0 ? 'resolved' : 'pending' @@ -294,35 +174,6 @@ class Hmr extends Service { await ready.promise } - private refreshConfig(key: object, filename: string, refresh: () => Promise | void) { - const state = this.configRefreshes.get(key) ?? { dirty: false } - this.configRefreshes.set(key, state) - state.dirty = true - if (state.running) return - const task = (async () => { - do { - state.dirty = false - try { - await refresh() - } catch (reason) { - const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) - this.ctx.logger.warn('config reload at %C failed', filename) - this.ctx.logger.warn(error) - try { - await this.ctx.parallel('hmr/config-update-failed', filename, error) - } catch (rejection) { - this.ctx.logger.warn(rejection) - } - } - } while (state.dirty) - })().finally(() => { - state.running = undefined - this.refreshTasks.delete(task) - }) - state.running = task - this.refreshTasks.add(task) - } - // hide stack trace from HMR getOuterStack = (): string[] => [ // ' at HMR.partialReload ()', diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 681d6801b2..2a2f4e0ca9 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -44,8 +44,7 @@ function retryableWriteError(error: unknown): boolean { * Apply patch lists to an entry list — THE patch semantics of this include, * shared by mounting (`applyPatches`) and offline config tooling * (`dsh --dump-config`) so a dump can never drift from what boots. The input - * is never mutated and the result is always detached from it (even with no - * patches): patching or mounting shared entry objects would bake earlier + * is never mutated: patching shared entry objects would bake earlier patch * values into the cached parse, so repeated application (config hot-reloads) * could never revert a removed or changed patch. Inserted rows and override * values are cloned before they join the list for the same reason: a later @@ -66,8 +65,8 @@ export function applyEntryPatches( patches: PatchOptions[] | undefined, warn: (message: string, ...args: any[]) => void, ): EntryOptions[] { + if (!patches?.length) return [...data] data = structuredClone(data) - if (!patches?.length) return data const entryMap = new Map() const buildMap = (entries: EntryOptions[]) => { @@ -134,20 +133,6 @@ export function applyEntryPatches( return data } -type ConfigUpdateStage = 'read' | 'parse' | 'validate' - -interface ReadCandidate { - content: string - data: EntryOptions[] -} - -class ConfigFileError extends Error { - constructor(public readonly stage: ConfigUpdateStage, path: string, cause: unknown) { - super(`failed to ${stage} config file ${path}`, { cause }) - this.name = 'ConfigFileError' - } -} - /** Runtime patch applied to entries loaded from an included config file. */ export interface PatchOptions { id?: string @@ -196,7 +181,6 @@ export class Include extends EntryTree { private writeTask?: NodeJS.Timeout | undefined private pendingWrite?: EntryOptions[] private writeQueue: Promise = Promise.resolve() - private applyQueue: Promise = Promise.resolve() constructor(ctx: Context, public config: Include.Config) { super(ctx) @@ -210,31 +194,20 @@ export class Include extends EntryTree { this.readonly = !this.type this.ctx.baseUrl = new URL('.', pathToFileURL(this.filename)).href - ctx.on('internal/update', async (config, _, next) => { + ctx.on('internal/update', (config, _, next) => { if (config.path !== this.config.path) return next() - await this.enqueue(async () => { - const data = this.applyPatches(this.data!, config.patches) - await this.root.update(data) - this.config = config + // Veto the fiber restart (children update in place), but persist the new + // config ourselves — `Fiber.update` only assigns `this.config` behind + // `next()`, and a stale `this.config.patches` would make the next + // `refresh()` re-apply the old overlay. + this.config = config + this.root.update(this.applyPatches(this.data!, config.patches)).catch((error) => { + this.ctx.logger.warn('config update at %C failed', this.filename) + this.ctx.logger.warn(error) }) }) } - /** - * Serialize one child-tree mutation behind every earlier one. The group's - * transactional `update` is not reentrant: two concurrent applies (the init - * apply racing an HMR-triggered refresh from the watcher's initial scan) - * interleave create and rollback on the same entries and strand the include - * fiber without settling, so every apply path funnels through this queue. - * A predecessor's failure is its own caller's outcome and never gates the - * next task. - */ - private enqueue(task: () => Promise): Promise { - const run = this.applyQueue.then(task, task) - this.applyQueue = run.then(() => {}, () => {}) - return run - } - private async checkAccess() { if (!this.type) return try { @@ -244,87 +217,80 @@ export class Include extends EntryTree { } } - private async read(forced = false): Promise { - let content: string - try { - content = await readFile(this.filename, 'utf8') - } catch (error) { - throw new ConfigFileError('read', this.filename, error) - } - if (!forced && this.content === content) return + private async read(forced = false) { + const content = await readFile(this.filename, 'utf8') + if (!forced && this.content === content) return false let data: any - try { - if (this.type === 'application/yaml') { - data = yaml.load(content, { schema }) - } else if (this.type === 'application/json') { - data = JSON.parse(content) - } else { - const module = await import(/* @vite-ignore */ this.filename) - data = module.default || module - } - } catch (error) { - throw new ConfigFileError('parse', this.filename, error) + if (this.type === 'application/yaml') { + data = yaml.load(content, { schema: entryListSchema }) + } else if (this.type === 'application/json') { + data = JSON.parse(content) + } else { + const module = await import(/* @vite-ignore */ this.filename) + data = module.default || module } + // An empty or truncated file (common mid-edit: editors and `sed -i` write + // through temp states) parses to `undefined`, not an error; reject every + // non-array shape here so callers see one "invalid file" signal. Content + // and data commit only on success, so an edit that is later reverted to + // the exact last good content correctly reads as "unchanged". if (!Array.isArray(data)) { - throw new ConfigFileError('validate', this.filename, new TypeError('config file must be a top-level array')) + throw new TypeError(`config file must be a top-level array of entries: ${this.filename}`) } - return { content, data } + this.content = content + this.data = data + await this.checkAccess() + return true } - private applyPatches(data: EntryOptions[], patches?: PatchOptions[]): EntryOptions[] { + private applyPatches(data: EntryOptions[], patches = this.config.patches): EntryOptions[] { return applyEntryPatches(data, patches, (message, ...args) => { this.ctx.root.logger?.('loader').warn(message, ...args) }) } async* [Service.init]() { - let candidate: ReadCandidate try { - candidate = (await this.read(true))! + await this.read() } catch (error) { - if (!(error instanceof ConfigFileError) || error.stage !== 'read' || (error.cause as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error + // Only a missing file falls back to `initial` (or the not-found error): + // an existing-but-invalid file must fail loud with its real parse error, + // never be mislabelled as absent or silently overwritten. + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error if (this.config.initial) { await this._writeFile(this.config.initial as any) - candidate = (await this.read(true))! + await this.read(true) } else { throw new Error(`config file not found: ${this.filename}`) } } yield () => this.stop() - await this.apply(candidate) + await this.root.update(this.applyPatches(this.data!)) } async stop() { - await this.root.stop() - await this.flushWrite() + try { + await this.flushWrite() + } finally { + this.root.stop() + await this.flushWrite() + } } /** - * Re-read the file and transactionally refresh child entries when content changed. - * @returns a promise resolving after the new tree commits, or immediately when unchanged. - * @throws when reading, parsing, validation, application, or rollback fails; the last good tree remains active when rollback succeeds. + * Re-read the file and refresh child entries when content changed. An + * unreadable or unparsable file logs a warning and keeps the last good + * tree: a hot-reload of a live app must never take the process down. */ async refresh() { - // Read inside the queue so the changed-content check compares against the - // predecessor's committed state, not a mid-apply snapshot. - await this.enqueue(async () => { - const candidate = await this.read() - if (!candidate) return - await this._apply(candidate) - }) - } - - private apply(candidate: ReadCandidate) { - return this.enqueue(() => this._apply(candidate)) - } - - private async _apply(candidate: ReadCandidate) { - const data = this.applyPatches(candidate.data, this.config.patches) - await this.root.update(data) - this.content = candidate.content - this.data = candidate.data - await this.checkAccess() + try { + if (!await this.read()) return + await this.root.update(this.applyPatches(this.data!)) + } catch (error) { + this.ctx.logger.warn('config reload at %C failed; keeping the running tree', this.filename) + this.ctx.logger.warn(error) + } } private async _writeFile(config: EntryOptions[]) { diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 8c5302d911..be03513096 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -32,10 +32,6 @@ export class EntryUpdateError extends Error { } } -function updateError(stage: EntryUpdateStage, options: EntryOptions, cause: unknown) { - return new EntryUpdateError(stage, options, cause) -} - function takeEntries(object: {}, keys: string[]) { const result: [string, any][] = [] for (const key of keys) { @@ -53,11 +49,6 @@ function sortKeys(object: T, prepend = ['id', 'name'], append = [' return Object.assign(object, Object.fromEntries([...part1, ...rest, ...part2])) } -function replaceKeys(target: T, source: T): T { - for (const key of Object.keys(target)) Reflect.deleteProperty(target, key) - return Object.assign(target, source) -} - /** One configured plugin node inside an `EntryTree`. */ export class Entry { static readonly key = Symbol.for('cordis.entry') @@ -71,7 +62,6 @@ export class Entry { public subtree?: EntryTree _initTask?: Promise - _disposing = 0 constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -92,18 +82,13 @@ export class Entry { /** True when this entry or any owning parent entry is disabled. */ get disabled() { - return this._disabled(this.options) - } - - private _disabled(options: EntryOptions) { // group is always enabled - if (options.group) return false - if (this.disabledOf(options)) return true - let entry = this.parent.ctx.fiber.entry - while (entry) { + if (this.options.group) return false + let entry: Entry | undefined = this + do { if (this.disabledOf(entry.options)) return true entry = entry.parent.ctx.fiber.entry - } + } while (entry) return false } @@ -121,12 +106,12 @@ export class Entry { return evaluate(this.ctx, expr) } - private async _patchContext(diff: string[]) { - await this.context.waterfall('loader/patch-context', this, async () => { + private _patchContext(diff: string[]) { + this.context.waterfall('loader/patch-context', this, () => { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - await this.fiber.update(this.options.config, true) + this.fiber.update(this.options.config, true) } }) } @@ -137,122 +122,41 @@ export class Entry { await this.init() } - async _dispose(fiber = this.fiber) { - if (!fiber) return - if (this.fiber === fiber) this.fiber = undefined - this._disposing += 1 - try { - await fiber.dispose() - } finally { - this._disposing -= 1 - } - } - /** Merge new options, restart as needed, and persist through the parent tree. */ async update(options: Partial, create = false, force = false) { - const previousOptions = this.options - const legacy = { ...previousOptions } - const candidate = create ? options as EntryOptions : { ...previousOptions } - if (!create) { + const legacy = { ...this.options } + + // step 1: update options + if (create) { + this.options = options as EntryOptions + } else { for (const [key, value] of Object.entries(options)) { if (isNullable(value)) { - delete candidate[key as keyof EntryOptions] + delete this.options[key] } else { - candidate[key as keyof EntryOptions] = value as never + this.options[key] = value } } } - sortKeys(candidate) + sortKeys(this.options) - const diff = Object - .keys({ ...candidate, ...legacy }) - .filter(key => !deepEqual(candidate[key as keyof EntryOptions], legacy[key as keyof EntryOptions])) - if (!diff.length && !force) return - - const commit = () => { - if (create) return - this.options = replaceKeys(previousOptions, candidate) - } - - const previous = this.fiber - if (!previous?.uid) { - this.fiber = undefined - this.options = candidate - try { - if (!this._disabled(candidate)) await this.init() - } catch (error) { - this.options = previousOptions - throw error - } - commit() + // step 2: execute + if (this.disabled) { + this.fiber?.dispose() return } - if (this._disabled(candidate)) { - this.options = candidate - try { - await this._dispose(previous) - } catch (error) { - this.options = previousOptions - throw updateError('dispose', candidate, error) - } - commit() + // step 3: check if options are changed + if (this.fiber?.uid) { + const diff = Object + .keys({ ...this.options, ...legacy }) + .filter(key => !deepEqual(this.options[key], legacy[key])) + if (!diff.length && !force) return this.context.emit('loader/partial-dispose', this, legacy, true) - return + this._patchContext(diff) + } else { + await this.init() } - - const replace = diff.some(key => key === 'name' || key === 'inject' || key === 'group') - if (!replace) { - this.options = candidate - try { - await this._patchContext(diff) - } catch (error) { - this.options = previousOptions - try { - await this._patchContext(diff) - } catch (rollbackError) { - throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) - } - this.context.emit('loader/partial-dispose', this, candidate, true) - throw updateError('apply', candidate, error) - } - commit() - this.context.emit('loader/partial-dispose', this, legacy, true) - return - } - - let plugin: any - try { - plugin = diff.includes('name') - ? this.loader.unwrapExports(await this.parent.tree.import(candidate.name, this.getOuterStack)) - : previous.runtime!.callback - } catch (error) { - throw updateError('import', candidate, error) - } - - const previousPlugin = previous.runtime!.callback - this.options = candidate - try { - await this._dispose(previous) - } catch (error) { - this.options = previousOptions - throw updateError('dispose', candidate, error) - } - - try { - await this._start(plugin) - } catch (error) { - this.options = previousOptions - try { - await this._start(previousPlugin) - } catch (rollbackError) { - throw updateError('rollback', legacy, new AggregateError([error, rollbackError])) - } - this.context.emit('loader/partial-dispose', this, candidate, true) - throw updateError('apply', candidate, error) - } - commit() - this.context.emit('loader/partial-dispose', this, legacy, true) } getOuterStack = () => { @@ -271,43 +175,27 @@ export class Entry { await (this._initTask ??= this._init()) } finally { this._initTask = undefined - if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - await this._await() - } - - async _await() { - try { - await this.fiber?.await() - } catch (error) { - throw updateError('apply', this.options, error) + const notify = () => { + if (this.loader.getTasks().length) return + this.ctx.reflect.notify(['loader']) } + this.fiber?.await().then(notify, notify) } private async _init() { - let plugin: any + let exports: any try { - plugin = this.loader.unwrapExports(await this.parent.tree.import(this.options.name, this.getOuterStack)) + exports = await this.parent.tree.import(this.options.name, this.getOuterStack) } catch (error) { - throw updateError('import', this.options, error) - } - try { - await this._start(plugin) - } catch (error) { - throw updateError('apply', this.options, error) - } - } - - private async _start(plugin: any) { - let fiber: Fiber | undefined - try { - await this._patchContext([]) - this.loader.showLog(this, 'apply') - fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) - await fiber.await() - } catch (error) { - await this._dispose(fiber) - throw error + this.ctx.logger.error(error) + return + } finally { + this._initTask = undefined } + const plugin = this.loader.unwrapExports(exports) + this._patchContext([]) + this.loader.showLog(this, 'apply') + this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) } } diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index 2e322b1e26..15d4d49e4b 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -19,23 +19,12 @@ export class EntryGroup { async create(options: Omit) { const id = this.tree.ensureId(options) - const existing = this.tree.store[id] - const entry: Entry = existing ?? (this.tree.store[id] = new Entry(this.ctx.loader)) - const previousParent = entry.parent + const entry: Entry = this.tree.store[id] ??= new Entry(this.ctx.loader) // Entry may be moved from another group, // so we need to update the parent reference. entry.parent = this // Use `create: true` to replace existing entry.options. - try { - await entry.update(options, true, true) - } catch (error) { - if (existing) { - entry.parent = previousParent - } else { - delete this.tree.store[id] - } - throw error - } + await entry.update(options, true, true) return entry.id } @@ -45,10 +34,10 @@ export class EntryGroup { if (index >= 0) config.splice(index, 1) } - async remove(id: string, isDispose = false) { + remove(id: string, isDispose = false) { const entry = this.tree.store[id] if (!entry) return - await entry._dispose() + entry.fiber?.dispose() if (!isDispose) { this.unlink(entry.options) } @@ -58,56 +47,26 @@ export class EntryGroup { async update(config: EntryOptions[]) { const oldConfig = this.data as EntryOptions[] - const seen = new Set() - for (const options of config) { - const id = this.tree.ensureId(options) - if (seen.has(id)) throw new TypeError(`duplicate loader entry id: ${id}`) - seen.add(id) - } + this.data = config const oldMap = Object.fromEntries(oldConfig.map(options => [options.id, options])) - const newMap = Object.fromEntries(config.map(options => [options.id, options])) + const newMap = Object.fromEntries(config.map(options => [options.id ?? Symbol('anonymous'), options])) - try { - const outcomes = await Promise.allSettled(config.map(options => this.create(options))) - // Disposal owns termination: sibling starts can still be settling after - // the containing tree has gone away, but their failures no longer - // describe a live update to roll back. - if (this.ctx.fiber.uid === null) return - const failures = outcomes - .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') - .map(outcome => outcome.reason) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'loader entries failed to apply') - for (const id of Object.keys(oldMap)) { - if (!newMap[id]) await this.remove(id, true) + // update inner plugins + const ids = Reflect.ownKeys({ ...oldMap, ...newMap }) as string[] + await Promise.all(ids.map(async (id) => { + if (newMap[id]) { + await this.create(newMap[id]).catch((error) => { + this.ctx.logger.error(error) + }) + } else { + this.remove(id) } - this.data = config - } catch (error) { - const rollbackErrors: unknown[] = [] - for (const id of Object.keys(newMap).reverse()) { - if (oldMap[id]) continue - try { - await this.remove(id, true) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - } - for (const options of oldConfig) { - try { - await this.create(options) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - } - this.data = oldConfig - if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], 'loader entry rollback failed') - throw error - } + })) } - async stop() { + stop() { for (const options of this.data) { - await this.remove(options.id, true) + this.remove(options.id, true) } } } @@ -119,7 +78,9 @@ export class Group extends EntryGroup { constructor(public ctx: Context, public config: EntryOptions[]) { super(ctx, ctx.fiber.entry!.parent.tree) - ctx.on('internal/update', config => this.update(config)) + ctx.on('internal/update', (config) => { + this.update(config) + }) } async* [Service.init]() { diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 0dd2a06c92..e2fd539157 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -93,7 +93,7 @@ export default function isolate(ctx: Context) { entry.ctx[Context.isolate] = Object.create(entry.ctx[Context.isolate]) }) - ctx.on('loader/patch-context', async (entry, next) => { + ctx.on('loader/patch-context', (entry, next) => { // step 1: generate new isolate map const newMap: Dict = Object.create(entry.parent.ctx[Context.isolate]) for (const name of Object.keys(entry.options.isolate ?? {})) { @@ -126,7 +126,7 @@ export default function isolate(ctx: Context) { swap(entry.ctx[Context.intercept], entry.options.intercept) // step 4: reload fiber - await next() + next() // step 5: replace service impl for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index c1925f0b90..b75d641375 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -39,27 +39,12 @@ export abstract class EntryTree { .filter(isNonNullable) } - /** - * Wait until this tree has no active import or lifecycle tasks. - * @throws a settled fiber failure, or an aggregate when several fibers failed. - */ + /** Wait until this tree has no pending import or lifecycle tasks. */ async await() { while (true) { const tasks = this.getTasks() - if (tasks.length) { - await Promise.allSettled(tasks) - continue - } - const outcomes = await Promise.allSettled( - [...this.entries()].map(entry => entry._await()), - ) - const failures = outcomes - .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') - .map(outcome => outcome.reason) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'loader fibers failed') - this.ctx.reflect.notify(['loader']) - if (!this.getTasks().length) return + if (!tasks.length) return + await Promise.allSettled(tasks) } } @@ -96,17 +81,15 @@ export abstract class EntryTree { /** Create an entry in the root group or a nested group. */ async create(options: Omit, parent: string | null = null, position = Infinity) { const group = this.resolveGroup(parent) - const id = await group.create(options) - const entry = this.resolve(id) - group.data.splice(position, 0, entry.options) + group.data.splice(position, 0, options as EntryOptions) group.tree.write() - return id + return group.create(options) } /** Stop and remove an entry from its parent group. */ - async remove(id: string) { + remove(id: string) { const entry = this.resolve(id) - await entry.parent.remove(id) + entry.parent.remove(id) entry.parent.tree.write() } @@ -114,31 +97,15 @@ export abstract class EntryTree { async update(id: string, options: Omit, parent?: string | null, position?: number) { const entry = this.resolve(id) const source = entry.parent - const sourceIndex = source.data.indexOf(entry.options) - let target = source if (parent !== undefined) { - target = this.resolveGroup(parent) + const target = this.resolveGroup(parent) source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) + target.tree.write() entry.parent = target } - try { - await entry.update(options, false, true) - } catch (error) { - if (parent !== undefined) { - target.unlink(entry.options) - source.data.splice(sourceIndex < 0 ? source.data.length : sourceIndex, 0, entry.options) - entry.parent = source - try { - await entry.update({}, false, true) - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], `failed to roll back loader entry move ${id}`) - } - } - throw error - } source.tree.write() - if (target !== source) target.tree.write() + return entry.update(options, false, true) } /** Import a plugin module from a specifier or `cordis:` builtin. */ diff --git a/vendor/loader/src/config/utils.ts b/vendor/loader/src/config/utils.ts index cd706c0407..8e28be0c4c 100644 --- a/vendor/loader/src/config/utils.ts +++ b/vendor/loader/src/config/utils.ts @@ -8,7 +8,7 @@ export const evaluate = new Function('ctx', 'expr', ` } `) as ((ctx: object, expr: string) => any) -/** Recursively replace YAML `!js` expression nodes with evaluated values. */ +/** Recursively replace YAML `!!js` expression nodes with evaluated values. */ export function interpolate(ctx: object, value: any) { if (isJsExpr(value)) { return evaluate(ctx, value.__jsExpr) diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 781353b983..ac736ae356 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -26,7 +26,7 @@ declare module '@deepseek-ai/cordis' { 'loader/config-update'(): void 'loader/entry-init'(entry: Entry): void 'loader/partial-dispose'(entry: Entry, legacy: Partial, active: boolean): void - 'loader/patch-context'(entry: Entry, next: () => void | Promise): void | Promise + 'loader/patch-context'(entry: Entry, next: () => void): void } interface Context { @@ -100,12 +100,12 @@ export class Loader extends EntryTree { return interpolate(this.ctx, config) }, { global: true }) - ctx.on('internal/update', async function (config, noSave, next) { + ctx.on('internal/update', function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() - await next() const unparse = this.runtime?.Config?.['simplify'] this.entry.options.config = unparse ? unparse(config) : config this.entry.parent.tree.write() + return next() }, { global: true, prepend: true }) ctx.on('internal/update', function (config, _, next) { @@ -143,12 +143,9 @@ export class Loader extends EntryTree { const treeOwner = fiber.entry.parent.tree.ctx.fiber if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return - // case 6: Loader is replacing or removing this exact fiber - if (fiber.entry._disposing) return - this.showLog(fiber.entry, 'unload') - // case 7: fiber is disposed by loader behavior + // case 6: fiber is disposed by loader behavior // such as inject checker, config file update, ancestor group disable if (fiber.entry.disabled) return